Compare commits

..

No commits in common. "main" and "fm/tv-fb2-fix" have entirely different histories.

190 changed files with 617 additions and 8232 deletions

View file

@ -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

View file

@ -8,14 +8,6 @@ Wolfskin is an Android TV client for one household. It is released through Forge
The app uses Wolfskin launcher artwork and a lilac-on-`#07070a` theme by default on a fresh install. Existing theme choices and all upstream colour themes remain available under Settings → App theme. Settings → About credits and links to Wholphin by damontecres.
## Wolfskin TV updates
Settings opens on Requests. Press Down, then OK to open Updates; the row and screen show the installed app version. Left/Right selects an action, Down reaches Auto-check, and Up returns to the remembered action. Release notes keep focus inside the notes panel: Up/Down scrolls, and Back returns to the Release notes button.
Auto-check uses the same preference as classic settings and only checks for updates. Manual checks remain available when it is off. Download and Install update are separate, explicit actions; Android owns installation permission and confirmation. Returning from Android refreshes the installed version. Back can leave a download running, and returning to Updates shows the controller's current state.
Classic settings (trial) is the final Settings row. It opens the full original settings, including About → Update and More/Advanced → Updates. All existing settings groups remain available.
---
# Wholphin - an OSS Android TV client for Jellyfin

View file

@ -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<ApplicationExtension> {
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 ->

View file

@ -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=<monotonic code>" }
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() }
}
}

View file

@ -36,18 +36,10 @@
<action android:name="android.intent.action.VIEW" />
<data android:mimeType="video/*" />
</intent>
<intent>
<action android:name="android.intent.action.INSTALL_PACKAGE" />
<data android:mimeType="application/vnd.android.package-archive" />
</intent>
<intent>
<action android:name="android.settings.MANAGE_UNKNOWN_APP_SOURCES" />
</intent>
</queries>
<application
android:allowBackup="true"
android:hasFragileUserData="true"
android:banner="@mipmap/ic_banner"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
@ -77,13 +69,6 @@
<data android:scheme="wholphin" />
</intent-filter>
</activity>
<receiver
android:name="org.justinvisser.wolfskin.tv.update.UpdateInstallReceiver"
android:exported="false" />
<activity
android:name="org.justinvisser.wolfskin.tv.update.UpdateConfirmationActivity"
android:exported="false"
android:excludeFromRecents="true" />
<provider
android:name="androidx.core.content.FileProvider"

View file

@ -32,7 +32,6 @@ import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.RestoredSession
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.services.AppUpgradeHandler
@ -577,22 +576,12 @@ class MainActivityViewModel
val restoredSession =
if (signInAutomatically) {
Timber.v("Sign in automatically")
serverRepository.restoreSavedSession()
serverRepository.restoreLastSession()
} else {
serverRepository.getMostRecentServer()
}
Timber.d("Restore last session result=%s", restoredSession)
setupNavigationManager.navigateTo(restoredSession.destination)
if (signInAutomatically && restoredSession is RestoredSession.Success) {
viewModelScope.launchDefault {
try {
serverRepository.revalidateSession(restoredSession.currentUser)
} catch (exception: Exception) {
if (exception is kotlinx.coroutines.CancellationException) throw exception
Timber.w(exception, "Saved launch session revalidation failed")
}
}
}
}
}
}

View file

@ -17,7 +17,6 @@ import org.acra.ktx.initAcra
import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.model.ServerVersion
import timber.log.Timber
import org.justinvisser.wolfskin.tv.NetworkStartup
import javax.inject.Inject
@OptIn(ExperimentalComposeRuntimeApi::class)
@ -55,7 +54,6 @@ class WholphinApplication :
override fun onCreate() {
super.onCreate()
networkStartup.start()
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy
@ -112,9 +110,6 @@ class WholphinApplication :
@Inject
lateinit var workerFactory: HiltWorkerFactory
@Inject
lateinit var networkStartup: NetworkStartup
override val workManagerConfiguration: Configuration
get() =
Configuration

View file

@ -51,10 +51,6 @@ interface JellyfinServerDao {
return user.copy(rowId = result.toInt())
}
/** Network name refreshes must not overwrite settings or protection changed since launch. */
@Query("UPDATE users SET name = :name WHERE serverId = :serverId AND id = :userId")
fun updateUserName(serverId: UUID, userId: UUID, name: String?): Int
@Query("SELECT * FROM users WHERE serverId = :serverId AND id = :userId")
fun getUser(
serverId: UUID,

View file

@ -8,8 +8,6 @@ import com.github.damontecres.wholphin.services.hilt.IoDispatcher
import com.github.damontecres.wholphin.util.WholphinDispatchers
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
@ -103,7 +101,15 @@ class ServerRepository
}
Timber.v("Changing user to ${user.name} on ${server.url}")
apiClient.update(baseUrl = server.url, accessToken = user.accessToken)
val (updatedServer, userDto) = readSession(server)
val userDto by apiClient.userApi.getCurrentUser()
val updatedServer =
try {
val sysInfo by apiClient.systemApi.getPublicSystemInfo()
server.copy(name = sysInfo.serverName, version = sysInfo.version)
} catch (ex: Exception) {
Timber.w(ex, "Exception fetching public system info")
server
}
var updatedUser =
user.copy(
id = userDto.id,
@ -119,45 +125,6 @@ class ServerRepository
return@withContext currentUser
}
/** Restore only an unprotected saved login; the launch picker must not wait for the network. */
suspend fun restoreSavedSession(): RestoredSession = withContext(ioDispatcher) {
val recent = mostRecentServerProvider.get()
if (recent !is MostRecentServer.ServerAndUser) return@withContext getMostRecentServerInternal(recent)
val saved = serverDao.getServer(recent.serverId) ?: return@withContext RestoredSession.None
val user = saved.users.firstOrNull { it.id == recent.userId }
if (user == null || user.isProtected) return@withContext RestoredSession.ServerOnly(saved.server)
apiClient.update(baseUrl = saved.server.url, accessToken = user.accessToken)
val current = CurrentUser(saved.server, user)
_currentUserDto.value = null
_current.value = current
RestoredSession.Success(current)
}
/** A late launch refresh must never replace a person selected while it was in flight. */
suspend fun revalidateSession(expected: CurrentUser) = withContext(ioDispatcher) {
val (server, userDto) = readSession(expected.server)
if (_current.value !== expected || userDto.id != expected.user.id) return@withContext
serverDao.addOrUpdateServer(server)
serverDao.updateUserName(expected.server.id, expected.user.id, userDto.name)
val user = serverDao.getUser(expected.server.id, expected.user.id) ?: return@withContext
if (_current.compareAndSet(expected, CurrentUser(server, user))) updateUserDto(userDto)
}
private suspend fun readSession(server: JellyfinServer): Pair<JellyfinServer, UserDto> = coroutineScope {
val system = async {
try {
val info by apiClient.systemApi.getPublicSystemInfo()
server.copy(name = info.serverName, version = info.version)
} catch (exception: Exception) {
if (exception is kotlinx.coroutines.CancellationException) throw exception
Timber.w(exception, "Exception fetching public system info")
server
}
}
val user by apiClient.userApi.getCurrentUser()
system.await() to user
}
/**
* Try to restore the most recent session
*

View file

@ -16,7 +16,6 @@ import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
import com.github.damontecres.wholphin.ui.preferences.PreferenceValidation
import com.github.damontecres.wholphin.util.DebugLogTree
import org.justinvisser.wolfskin.tv.update.UpdateChannel
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes
@ -628,7 +627,7 @@ sealed interface AppPreference<Pref, T> {
val UpdateUrl =
AppStringPreference<AppPreferences>(
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 }

View file

@ -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<AndroidUpdateService>,
) {
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 =

View file

@ -26,9 +26,6 @@ import org.jellyfin.sdk.api.okhttp.OkHttpFactory
import org.jellyfin.sdk.createJellyfin
import org.jellyfin.sdk.model.ClientInfo
import org.jellyfin.sdk.model.DeviceInfo
import org.justinvisser.wolfskin.conductarr.PersistentDns
import org.justinvisser.wolfskin.conductarr.DnsConnectionRetry
import java.io.File
import javax.inject.Qualifier
import javax.inject.Singleton
@ -94,14 +91,12 @@ object AppModule {
@StandardOkHttpClient
@Provides
@Singleton
fun okHttpClient(dns: PersistentDns) =
DnsConnectionRetry.configure(OkHttpClient.Builder(), dns).build()
/** Resolve noBackupFilesDir lazily: Android may create it and lookup always runs off main. */
@Provides
@Singleton
fun dns(@ApplicationContext context: Context): PersistentDns =
PersistentDns(file = { File(context.noBackupFilesDir, "wolfskin-dns.json") })
fun okHttpClient() =
OkHttpClient
.Builder()
.apply {
// TODO user agent, timeouts, logging, etc
}.build()
@AuthOkHttpClient
@Provides

View file

@ -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(

View file

@ -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,

View file

@ -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()

View file

@ -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) }
}
}

View file

@ -4,11 +4,6 @@ import android.content.Context
import com.github.damontecres.wholphin.data.MostRecentServer
import com.github.damontecres.wholphin.services.hilt.AppModule
import com.github.damontecres.wholphin.services.hilt.DeviceModule
import com.github.damontecres.wholphin.services.hilt.StandardOkHttpClient
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import com.google.auto.service.AutoService
import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient
@ -78,7 +73,10 @@ class CrashReportSender : ReportSender {
private fun MostRecentServer.createApi(context: Context): ApiClient? =
(this as? MostRecentServer.ServerAndUser)?.let {
val okHttpClient = EntryPointAccessors.fromApplication(context.applicationContext, CrashNetwork::class.java).client()
val okHttpClient =
OkHttpClient
.Builder()
.build()
val okHttpFactory = OkHttpFactory(okHttpClient)
val api =
createJellyfin {
@ -91,11 +89,3 @@ private fun MostRecentServer.createApi(context: Context): ApiClient? =
}.createApi(baseUrl = serverUrl, accessToken = accessToken)
api
}
/** Crash reporting shares the application's DNS hints without borrowing authenticated headers. */
@EntryPoint
@InstallIn(SingletonComponent::class)
interface CrashNetwork {
@StandardOkHttpClient
fun client(): OkHttpClient
}

View file

@ -32,8 +32,6 @@ import io.mockk.mockkStatic
import io.mockk.slot
import io.mockk.unmockkStatic
import io.mockk.verify
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flow
@ -118,6 +116,9 @@ class TestMainActivityViewModel {
signInAutomatically = true
}
every { serverRepository.current } returns MutableStateFlow(currentUser)
// coEvery { serverRepository.tryRestoreSession(serverId, userId) } returns currentUser
// coEvery { serverRepository.restoreLastSession() } returns
// RestoredSession.Success(currentUser)
viewModel.handleAppStart()
@ -131,7 +132,7 @@ class TestMainActivityViewModel {
destination as SetupDestination.AppContent
Assert.assertEquals(currentUser, destination.current)
}
coVerify(exactly = 0) { serverRepository.restoreSavedSession() }
coVerify(exactly = 0) { serverRepository.restoreLastSession() }
coVerify(exactly = 0) { serverRepository.getMostRecentServer() }
}
@ -149,7 +150,7 @@ class TestMainActivityViewModel {
viewModel.handleAppStart()
coVerify(exactly = 0) { serverRepository.tryChangeUser(any(), any()) }
coVerify(exactly = 0) { serverRepository.restoreSavedSession() }
coVerify(exactly = 0) { serverRepository.restoreLastSession() }
coVerify(exactly = 1) { serverRepository.getMostRecentServer() }
val args = mutableListOf<SetupDestination>()
verify { setupNavigationManager.navigateTo(capture(args)) }
@ -164,20 +165,6 @@ class TestMainActivityViewModel {
}
}
@Test
fun savedColdLaunchNavigatesBeforeSessionRevalidationCompletes() = runTest(testDispatcher) {
setupPreferences { signInAutomatically = true }
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null)
coEvery { serverRepository.restoreSavedSession() } returns RestoredSession.Success(currentUser)
val release = CompletableDeferred<Unit>()
coEvery { serverRepository.revalidateSession(currentUser) } coAnswers { release.await() }
viewModel.handleAppStart()
runCurrent()
verify { setupNavigationManager.navigateTo(SetupDestination.AppContent(currentUser)) }
assertFalse(release.isCompleted)
release.complete(Unit)
}
@Test
fun `Test auto sign in, hot load, with protected profile`() =
runTest {
@ -191,7 +178,7 @@ class TestMainActivityViewModel {
coEvery {
serverRepository.tryChangeUser(serverId, userId)
} returns null
coEvery { serverRepository.restoreSavedSession() } returns
coEvery { serverRepository.restoreLastSession() } returns
RestoredSession.ServerOnly(server)
viewModel.handleAppStart()
@ -217,7 +204,7 @@ class TestMainActivityViewModel {
signInAutomatically = true
}
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null)
coEvery { serverRepository.restoreSavedSession() } returns
coEvery { serverRepository.restoreLastSession() } returns
RestoredSession.ServerOnly(server)
coEvery {
serverRepository.tryChangeUser(serverId, userId)
@ -225,7 +212,7 @@ class TestMainActivityViewModel {
viewModel.handleAppStart()
coVerify(exactly = 1) { serverRepository.restoreSavedSession() }
coVerify(exactly = 1) { serverRepository.restoreLastSession() }
val slot = slot<SetupDestination>()
verify { setupNavigationManager.navigateTo(capture(slot)) }
slot.captured.let { destination ->
@ -245,11 +232,11 @@ class TestMainActivityViewModel {
signInAutomatically = true
}
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null)
coEvery { serverRepository.restoreSavedSession() } returns RestoredSession.None
coEvery { serverRepository.restoreLastSession() } returns RestoredSession.None
viewModel.handleAppStart()
coVerify(exactly = 1) { serverRepository.restoreSavedSession() }
coVerify(exactly = 1) { serverRepository.restoreLastSession() }
val slot = slot<SetupDestination>()
verify { setupNavigationManager.navigateTo(capture(slot)) }
slot.captured.let { destination ->
@ -267,7 +254,7 @@ class TestMainActivityViewModel {
signInAutomatically = true
}
every { serverRepository.current } returns MutableStateFlow(currentUser)
coEvery { serverRepository.restoreSavedSession() } returns
coEvery { serverRepository.restoreLastSession() } returns
RestoredSession.Success(currentUser)
coEvery { intentService.parseIntent(any()) } returns IntentResult.NoOp
@ -284,7 +271,7 @@ class TestMainActivityViewModel {
// destination as SetupDestination.AppContent
// Assert.assertEquals(currentUser, destination.current)
// }
coVerify(exactly = 0) { serverRepository.restoreSavedSession() }
coVerify(exactly = 0) { serverRepository.restoreLastSession() }
coVerify(exactly = 0) { serverRepository.getMostRecentServer() }
}

View file

@ -1,136 +0,0 @@
package com.github.damontecres.wholphin.services
import android.app.Application
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import android.content.Context
import com.github.damontecres.wholphin.data.*
import com.github.damontecres.wholphin.data.model.*
import com.github.damontecres.wholphin.ui.successResponse
import io.mockk.*
import java.util.UUID
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.systemApi
import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.api.operations.SystemApi
import org.jellyfin.sdk.api.operations.UserApi
import org.jellyfin.sdk.model.api.PublicSystemInfo
import org.jellyfin.sdk.model.api.UserDto
import org.junit.Assert.*
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/** Saved identities can paint offline without unlocking protected logins or accepting a late identity. */
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class)
class ServerSavedSessionTest {
private val dispatcher = StandardTestDispatcher()
private val server = JellyfinServer(UUID.randomUUID(), "Saved server", "https://jellyfin.test", "12.1.0")
private var user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Saved name", accessToken = "saved-token")
private val users = mockk<UserApi>()
private val system = mockk<SystemApi>()
private val api = mockk<ApiClient>(relaxed = true)
private val recent = mockk<MostRecentServerProvider>()
private val database by lazy {
Room.inMemoryDatabaseBuilder(ApplicationProvider.getApplicationContext<Application>(), AppDatabase::class.java)
.allowMainThreadQueries().build()
}
private val dao by lazy { database.serverDao() }
private val dto = UserDto(id = user.id, name = "Refreshed name",
hasPassword = false, hasConfiguredPassword = false, hasConfiguredEasyPassword = false)
private val info = PublicSystemInfo(serverName = "Refreshed server", version = "12.1.0")
private val repository by lazy { ServerRepository(mockk<Context>(), mockk(), dao, api, dispatcher, recent) }
@Before fun savedLogin() {
every { recent.get() } returns MostRecentServer.from(CurrentUser(server, user))
dao.addOrUpdateServer(server)
user = dao.addOrUpdateUser(user)
every { api.userApi } returns users
every { api.systemApi } returns system
coEvery { users.getCurrentUser() } returns successResponse(dto)
coEvery { system.getPublicSystemInfo() } returns successResponse(info)
}
@After fun closeDatabase() { database.close() }
@Test fun savedSessionIsUsableWhileUserRevalidationWaitsAndSystemInfoRunsInParallel() = runTest(dispatcher) {
val pendingUser = CompletableDeferred<Unit>()
val systemStarted = CompletableDeferred<Unit>()
coEvery { users.getCurrentUser() } coAnswers { pendingUser.await(); successResponse(dto) }
coEvery { system.getPublicSystemInfo() } coAnswers { systemStarted.complete(Unit); successResponse(info) }
val restored = repository.restoreSavedSession() as RestoredSession.Success
assertEquals(user, restored.currentUser.user)
assertEquals(restored.currentUser, repository.current.value)
val refresh = async { repository.revalidateSession(restored.currentUser) }
runCurrent()
systemStarted.await()
assertFalse(refresh.isCompleted)
pendingUser.complete(Unit)
refresh.await()
assertEquals("Refreshed name", repository.current.value!!.user.name)
assertEquals("Refreshed server", repository.current.value!!.server.name)
}
@Test fun savedProtectedPeopleRemainLocked() = runTest(dispatcher) {
for (protected in listOf(user.copy(pin = "1234"), user.copy(requireLogin = true))) {
dao.updateUser(protected)
assertTrue(repository.restoreSavedSession() is RestoredSession.ServerOnly)
assertNull(repository.current.value)
}
}
@Test fun lateLaunchRefreshCannotReplaceTheSelectedPerson() = runTest(dispatcher) {
val first = (repository.restoreSavedSession() as RestoredSession.Success).currentUser
val entered = CompletableDeferred<Unit>()
val pending = CompletableDeferred<Unit>()
coEvery { users.getCurrentUser() } coAnswers { entered.complete(Unit); pending.await(); successResponse(dto) }
val refresh = async { repository.revalidateSession(first) }
entered.await()
val selected = dao.addOrUpdateUser(user.copy(rowId = 0, id = UUID.randomUUID(), name = "Other person"))
every { recent.get() } returns MostRecentServer.from(CurrentUser(server, selected))
repository.restoreSavedSession()
pending.complete(Unit)
refresh.await()
assertEquals(selected, repository.current.value!!.user)
assertNull(repository.currentUserDto)
}
@Test fun launchNameRefreshPreservesSettingsAndPinChangedWhileTheRequestWaits() = runTest(dispatcher) {
val first = (repository.restoreSavedSession() as RestoredSession.Success).currentUser
val entered = CompletableDeferred<Unit>()
val pending = CompletableDeferred<Unit>()
coEvery { users.getCurrentUser() } coAnswers { entered.complete(Unit); pending.await(); successResponse(dto) }
val refresh = async { repository.revalidateSession(first) }
entered.await()
val edited = user.copy(
pin = "4567",
appPreferences = user.appPreferences.copy(preferredAudioLanguage = "nld"),
config = user.config.copy(homeSettingsSource = HomePageSettingsSource.LOCAL),
)
dao.updateUser(edited)
pending.complete(Unit)
refresh.await()
val expected = edited.copy(name = dto.name)
assertEquals(expected, dao.getUser(server.id, user.id))
assertEquals(expected, repository.current.value!!.user)
}
@Test fun anotherUsersResponseCannotRenameTheSavedPerson() = runTest(dispatcher) {
val first = (repository.restoreSavedSession() as RestoredSession.Success).currentUser
coEvery { users.getCurrentUser() } returns successResponse(dto.copy(id = UUID.randomUUID()))
repository.revalidateSession(first)
assertEquals(user, repository.current.value!!.user)
assertNull(repository.currentUserDto)
}
}

View file

@ -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 }

View file

@ -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

View file

@ -62,22 +62,16 @@ import org.jellyfin.sdk.api.okhttp.OkHttpFactory
import org.jellyfin.sdk.model.ClientInfo
import org.jellyfin.sdk.model.DeviceInfo
import timber.log.Timber
import org.justinvisser.wolfskin.conductarr.PersistentDns
import javax.inject.Singleton
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [DeviceModule::class, AppModule::class, org.justinvisser.wolfskin.tv.update.UpdateModule::class],
replaces = [DeviceModule::class, AppModule::class],
)
object TestModule {
val testDispatcher = StandardTestDispatcher()
@Provides
@Singleton
fun updateController(): org.justinvisser.wolfskin.tv.update.UpdateController =
org.justinvisser.wolfskin.tv.update.FakeUpdateController()
@Provides
@Singleton
fun deviceInfo(
@ -97,11 +91,12 @@ object TestModule {
@StandardOkHttpClient
@Provides
@Singleton
fun okHttpClient(dns: PersistentDns) = AppModule.okHttpClient(dns)
@Provides
@Singleton
fun dns(@ApplicationContext context: Context): PersistentDns = AppModule.dns(context)
fun okHttpClient() =
OkHttpClient
.Builder()
.apply {
// TODO user agent, timeouts, logging, etc
}.build()
@AuthOkHttpClient
@Provides

View file

@ -88,7 +88,7 @@ class AnimeAvailabilityTest {
}
}
val model = WolfskinShellViewModel(context, session, mockk(), mockk(), mockk(), mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
try {
requireNotNull(model.onForeground(server, user)).join()
assertEquals("$key with $count Anime titles", count > 0, "anime" in session.state!!.tabs)

View file

@ -69,7 +69,7 @@ class AnimeRootRemovalTest {
val navigation = NavigationManager()
val session = WolfskinSession()
val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(), mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
try {
model.load(server, user)
requireNotNull(model.onForeground(server, user)).join()

View file

@ -49,7 +49,7 @@ class ConductarrAudienceRecoveryTest {
val client = session.identity.value!!.client
val libraries = LibraryReadFacade(mockk())
val model = WolfskinShellViewModel(context, WolfskinSession(), mockk(), mockk(), mockk(), mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true))
try {
requireNotNull(model.onForeground(server, user)).join()
assertNull(libraries.audiences)
@ -82,7 +82,7 @@ class ConductarrAudienceRecoveryTest {
session.authenticate()
val libraries = LibraryReadFacade(mockk())
val model = WolfskinShellViewModel(context, WolfskinSession(), mockk(), mockk(), mockk(), mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true))
try {
val identity = session.identity.value!!
val intent = org.justinvisser.wolfskin.design.DiscoverRequest(

View file

@ -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<ComponentActivity>()
@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) } }
}

View file

@ -84,14 +84,14 @@ 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) })
}
val navigation = NavigationManager()
val session = WolfskinSession()
val model = WolfskinShellViewModel(rule.activity, session, repository, navigation, mockk(relaxed = true), mockk(relaxed = true),
LibraryReadFacade(api), WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
LibraryReadFacade(api), WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
val home = HomeSnapshot(listOf(HomeRow("resume", HomeRowKind.Hero, cards = listOf(
HomeTitle("episode", "Me and My Town", "episode", seriesTitle = "Desperate Housewives", season = 5, episode = 9)), heroKind = "resume")))
try {

View file

@ -51,7 +51,7 @@ class LibraryReadFacadeTest {
val offsets = mutableListOf<Int>()
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())

View file

@ -85,7 +85,7 @@ class PersonPickerSessionTest {
val navigation = remember { NavigationManager() }
val model = remember {
WolfskinShellViewModel(rule.activity, session, repository, navigation, setup, mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()).also { models.add(it) }
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)).also { models.add(it) }
}
DisposableEffect(model) { onDispose { model.viewModelScope.cancel() } }
WolfskinApp(server, current.user, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null),
@ -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 &&

View file

@ -87,7 +87,7 @@ class PersonSwitchThreadingTest {
coEvery { libraries.readDestinations(any()) } returns emptyMap()
val session = WolfskinSession()
val model = WolfskinShellViewModel(context, session, repository, mockk(relaxed = true), setup, mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
val clients = mutableListOf<ConductarrClient>()
try {
model.load(server, justin)
@ -163,7 +163,7 @@ class PersonSwitchThreadingTest {
coEvery { libraries.readDestinations(any()) } returns emptyMap()
val session = WolfskinSession()
val model = WolfskinShellViewModel(context, session, repository, mockk(relaxed = true), setup, mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
try {
model.load(server, justin)
requireNotNull(model.onForeground(server, justin)).join()

View file

@ -1,92 +0,0 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv
import android.app.Application
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import com.github.takahirom.roborazzi.captureRoboImage
import java.io.File
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinServerUsers
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.NavigationManager
import io.mockk.coEvery
import io.mockk.mockk
import java.util.UUID
import java.util.concurrent.CountDownLatch
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.cancel
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
import org.justinvisser.wolfskin.design.PeopleStatus
import org.justinvisser.wolfskin.tv.read.LibraryReadFacade
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** The actual picker displays every saved login while household revalidation has no response. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class SavedPeoplePickerTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test fun savedPeopleAreVisibleBeforeTheFirstNetworkResponse() {
ConductarrPreference.initialize(rule.activity)
val previousUrl = ConductarrPreference.baseUrl
val previousFixtures = ConductarrPreference.fixtures
val release = CountDownLatch(1)
val entered = AtomicBoolean(false)
FakeConductarr().use { fake ->
fake.beforeResponse = { entered.set(true); release.await() }
ConductarrPreference.updateBaseUrl(fake.url)
ConductarrPreference.updateFixtures(false)
ConductarrSession(MemoryCacheStore()).use { conductarr ->
val server = JellyfinServer(UUID.randomUUID(), "Saved server", fake.url, null)
val users = listOf("Justin" to JUSTIN_TOKEN, "Mindel" to MINDEL_TOKEN, "Shared" to SHARED_TOKEN).map { (name, token) ->
JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = name, accessToken = token)
}
val repository = mockk<ServerRepository>(relaxed = true)
coEvery { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, users)
val libraries = mockk<LibraryReadFacade>(relaxed = true)
coEvery { libraries.readDestinations(any()) } returns emptyMap()
val session = WolfskinSession()
val navigation = NavigationManager()
val model = WolfskinShellViewModel(rule.activity, session, repository, navigation, mockk(relaxed = true),
mockk(relaxed = true), libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
try {
rule.setContent {
WolfskinApp(server, users.first(), navigation, UserPreferences(AppPreferences.getDefaultInstance(), null),
viewModel = model, homeContent = { _, _, _, _ -> })
}
rule.waitUntil(5_000) { entered.get() && session.state?.people?.size == 3 }
users.forEach { rule.onNodeWithText(it.name!!).assertIsDisplayed() }
rule.runOnIdle {
assertTrue(session.state!!.pickerVisible)
assertEquals(PeopleStatus.Ready, session.state!!.peopleStatus)
assertEquals(users.map { it.id.toString() }, session.state!!.people.map { it.jellyfinUserId })
assertEquals(listOf("justin", "mindel", "shared"), session.state!!.people.map { it.key })
assertEquals(1L, release.count)
}
val captures = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))).apply { mkdirs() }
rule.onRoot().captureRoboImage(File(captures, "saved-people-before-network.png"))
} finally {
release.countDown()
model.viewModelScope.cancel()
ConductarrPreference.updateBaseUrl(previousUrl)
ConductarrPreference.updateFixtures(previousFixtures)
}
}
}
}
}

View file

@ -66,7 +66,7 @@ class ShellStartupTimingTest {
coEvery { libraries.readDestinations(user.id) } returns emptyMap()
val session = WolfskinSession()
val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(), mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
val load = launch { model.load(server, user) }
try {
withTimeout(5_000) { savedPeopleStarted.await() }
@ -121,7 +121,7 @@ class ShellStartupTimingTest {
coEvery { libraries.readDestinations(any()) } returns emptyMap()
val session = WolfskinSession()
val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(relaxed = true), mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
val pending = CompletableDeferred<Unit>()
val release = java.util.concurrent.CountDownLatch(1)
val firstAudience = java.util.concurrent.atomic.AtomicBoolean(true)

View file

@ -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<GetNextUpRequest>()) } coAnswers {
assertTrue(firstArg<GetNextUpRequest>().fields.orEmpty().isEmpty())
successQueryResult(emptyList())
}
coEvery { items.getItems(any<GetItemsRequest>()) } returns successQueryResult(
listOf(BaseItemDto(id = seasonId, type = BaseItemKind.SEASON, indexNumber = 3)))
coEvery { shows.getEpisodes(any<GetEpisodesRequest>()) } coAnswers {
val fields = firstArg<GetEpisodesRequest>().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<GetItemsRequest>()) } coAnswers {
val fields = firstArg<GetItemsRequest>().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

View file

@ -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<Map<String, List<String>>>()
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) }
}
}

View file

@ -1,108 +0,0 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv.update
import org.junit.Assert.*
import org.junit.Test
import org.justinvisser.wolfskin.design.ShellKey
/** Behavior boundaries are controller outcomes, not incidental UI wording. */
class UpdateBehaviorTest {
@Test fun everyOutcomeHasOnlyExplicitUserActions() {
val expected = listOf(
UpdateUiState.Idle to listOf(UpdateCommand.Check),
UpdateUiState.Checking to listOf(UpdateCommand.Back),
UpdateUiState.UpToDate to listOf(UpdateCommand.Check),
UpdateUiState.NoReleasePublished to listOf(UpdateCommand.Check),
updateReleaseFixture() to listOf(UpdateCommand.Download, UpdateCommand.Notes),
UpdateUiState.Downloading(12, 30) to listOf(UpdateCommand.Cancel),
UpdateUiState.Ready("1.1.1") to listOf(UpdateCommand.Install, UpdateCommand.Notes),
UpdateUiState.PermissionNeeded to listOf(UpdateCommand.Install, UpdateCommand.Cancel),
UpdateUiState.HandingOff to listOf(UpdateCommand.Cancel),
UpdateUiState.InstallCancelled to listOf(UpdateCommand.Install, UpdateCommand.Notes),
UpdateUiState.Offline to listOf(UpdateCommand.Retry),
UpdateUiState.NoInstaller to listOf(UpdateCommand.Retry, UpdateCommand.Classic),
) + UpdateFailure.entries.map { UpdateUiState.Failed(it, "operation failed", UpdateOperation.Download) to listOf(UpdateCommand.Retry, UpdateCommand.Back) }
expected.forEach { (state, commands) ->
assertEquals(state.toString(), commands, presentUpdate(state, updateReleaseFixture()).actions.map { it.command })
}
assertNotEquals(presentUpdate(UpdateUiState.UpToDate, null), presentUpdate(UpdateUiState.NoReleasePublished, null))
}
@Test fun refreshedInstalledIdentityDoesNotRequireOrMaskAChannelCheck() {
val installed = "1.1.1"
val refreshed = presentUpdate(UpdateUiState.Idle, null, installed)
assertNotEquals(presentUpdate(UpdateUiState.Idle, null), refreshed)
assertEquals(listOf(UpdateCommand.Check), refreshed.actions.map { it.command })
assertEquals(presentUpdate(UpdateUiState.Checking, null),
presentUpdate(UpdateUiState.Checking, null, installed))
}
@Test fun percentageRequiresPositiveTotalAndIsBounded() {
listOf(null, 0L, -1L).forEach { total ->
val ui = presentUpdate(UpdateUiState.Downloading(20_100_000, total), updateReleaseFixture())
assertNull(ui.fraction)
}
assertEquals(.42f, presentUpdate(UpdateUiState.Downloading(20_076_000, 47_800_000), null).fraction!!, .0001f)
assertEquals(1f, presentUpdate(UpdateUiState.Downloading(90, 30), null).fraction!!, 0f)
}
@Test fun actionsAndAutoCheckRememberFocusWithoutTogglingOnMovement() {
val controller = FakeUpdateController(updateReleaseFixture())
val focus = UpdateFocus(controller, {}, {}, {}, {})
focus.update(controller.state.value, presentUpdate(controller.state.value, controller.release.value).actions)
focus.key(ShellKey.Right)
focus.key(ShellKey.Down)
assertTrue(focus.autoFocused)
assertTrue(controller.autoCheck.value)
focus.key(ShellKey.Ok)
assertFalse(controller.autoCheck.value)
focus.key(ShellKey.Up)
assertFalse(focus.autoFocused)
assertEquals(1, focus.actionIndex)
assertFalse(controller.autoCheck.value)
}
@Test fun notesTrapFocusAndBackRestoresNotesAction() {
val controller = FakeUpdateController(updateReleaseFixture())
var backs = 0
var navs = 0
val steps = mutableListOf<Int>()
val focus = UpdateFocus(controller, { backs++ }, {}, { navs++ }, steps::add)
focus.update(controller.state.value, presentUpdate(controller.state.value, controller.release.value).actions)
focus.key(ShellKey.Right); focus.key(ShellKey.Ok)
listOf(ShellKey.Up, ShellKey.Down, ShellKey.Left, ShellKey.Right, ShellKey.Ok).forEach(focus::key)
assertTrue(focus.notesOpen)
assertEquals(listOf(-1, 1), steps)
assertEquals(0, navs)
assertTrue(controller.autoCheck.value)
focus.key(ShellKey.Back)
assertFalse(focus.notesOpen)
assertEquals(1, focus.actionIndex)
assertEquals(0, backs)
focus.key(ShellKey.Up)
assertEquals(1, navs)
focus.key(ShellKey.Back)
assertEquals(1, backs)
}
@Test fun progressUpdatesCannotStealFocusAndInstallerCancellationRestoresInstall() {
val controller = FakeUpdateController(UpdateUiState.Downloading(1, 100))
val focus = UpdateFocus(controller, {}, {}, {}, {})
fun update(state: UpdateUiState) { focus.update(state, presentUpdate(state, null).actions) }
update(controller.state.value)
focus.key(ShellKey.Down)
update(UpdateUiState.Downloading(90, 100))
assertTrue(focus.autoFocused)
update(UpdateUiState.Ready("1.1.1"))
assertFalse(focus.autoFocused)
focus.key(ShellKey.Right)
update(UpdateUiState.PermissionNeeded)
update(UpdateUiState.InstallCancelled)
assertEquals(0, focus.actionIndex)
assertFalse(focus.autoFocused)
}
}
/** Same release facts as the approved television frames. */
fun updateReleaseFixture() = UpdateUiState.Available("1.1.1", 47_800_000, "6 Nov 2026",
"Updates inside Wolfskin settings.\nKeep sign-ins and preferences.\nClassic settings remains for a trial.")

View file

@ -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) }
}
}

View file

@ -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)
}
}
}

View file

@ -1,175 +0,0 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv.update
import android.app.Application
import android.view.View
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.semantics.SemanticsProperties
import com.github.takahirom.roborazzi.captureRoboImage
import java.io.File
import org.junit.Assert.*
import org.junit.Before
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
import org.robolectric.annotation.GraphicsMode
/** Native D-pad behavior preserves the retained shell, action focus and real scroller. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class UpdatesKeysTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private val shell = ShellState(listOf(ShellPerson("justin", "Justin", "J", true)), "justin").apply {
pickerVisible = false
enterDestination("updates")
settingsRow = 1
}
private lateinit var controller: FakeUpdateController
private val reduced = mutableStateOf(false)
private val directory get() = System.getProperty("wolfskin.updater.captureDirectory")?.let(::File)
@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
}
private fun launch(state: UpdateUiState, auto: Boolean = true, settings: Boolean = false) {
controller = FakeUpdateController(state, auto, InstalledVersion("1.1.0-140-g5162c299", 100000), state as? UpdateUiState.Available ?: updateReleaseFixture())
if (settings) shell.enterDestination("settings")
rule.setContent {
CompositionLocalProvider(LocalReducedMotion provides reduced.value) {
WolfskinTheme {
ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight),
settingsInstalledVersion = controller.installedVersion.value.versionName) { modifier ->
UpdatesScreen(shell, controller, { shell.navFocused = false; shell.enterDestination("settings") }, {}, modifier)
}
}
}
}
rule.mainClock.advanceTimeBy(1000)
rule.waitForIdle()
}
private fun capture(name: String) {
val target = directory ?: return
target.mkdirs()
rule.waitForIdle()
val file = File(target, "$name.png")
file.parentFile?.mkdirs()
rule.onRoot().captureRoboImage(file.absolutePath)
}
private fun press(keys: List<Key>) {
keys.forEach { key ->
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) }
rule.runOnIdle { androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
rule.mainClock.advanceTimeByFrame()
}
rule.mainClock.advanceTimeBy(320)
rule.waitForIdle()
}
@Test fun classicRequiresEveryRetainedRowAndTheRealScroller() {
shell.settingsRow = 0
launch(UpdateUiState.Checking, settings = true)
repeat(9) { press(listOf(Key.DirectionDown)) }
assertEquals(9, shell.settingsRow)
assertEquals(4, shell.view.listState.firstVisibleItemIndex)
capture("classic")
press(listOf(Key.DirectionCenter))
assertEquals("classic", shell.handOff)
}
@Test fun notesTrapRealKeysAndBackPreservesSettingsRowAndScroller() {
launch(updateReleaseFixture())
press(listOf(Key.DirectionRight, Key.DirectionCenter, Key.DirectionUp, Key.DirectionRight, Key.DirectionDown))
assertTrue(shell.contentOverlayVisible)
assertFalse(shell.navFocused)
assertTrue(controller.autoCheck.value)
press(listOf(Key.Back))
assertFalse(shell.contentOverlayVisible)
assertEquals(1, (shell.contentFocus as UpdateFocus).actionIndex)
press(listOf(Key.Back))
assertEquals("settings", shell.route)
assertEquals(1, shell.settingsRow)
assertEquals(0, shell.view.listState.firstVisibleItemIndex)
}
@Test fun longNotesScrollWithoutEscapingToNavOrAutoCheck() {
launch(updateReleaseFixture().copy(notes = (1..30).joinToString("\n") { "Release detail $it" }))
press(listOf(Key.DirectionRight, Key.DirectionCenter, Key.DirectionDown, Key.DirectionDown))
val range = rule.onNodeWithTag("update-notes").fetchSemanticsNode().config[SemanticsProperties.VerticalScrollAxisRange]
assertTrue(range.value() > 0f)
assertFalse(shell.navFocused)
assertFalse((shell.contentFocus as UpdateFocus).autoFocused)
press(listOf(Key.Back))
assertEquals(1, (shell.contentFocus as UpdateFocus).actionIndex)
}
@Test fun navAndAutoCheckReturnToTheRememberedActionWithoutToggling() {
launch(updateReleaseFixture())
press(listOf(Key.DirectionRight, Key.DirectionDown, Key.DirectionUp))
assertEquals(1, (shell.contentFocus as UpdateFocus).actionIndex)
assertTrue(controller.autoCheck.value)
press(listOf(Key.DirectionUp))
assertTrue(shell.navFocused)
press(listOf(Key.DirectionDown))
assertFalse(shell.navFocused)
assertEquals(1, (shell.contentFocus as UpdateFocus).actionIndex)
assertTrue(controller.autoCheck.value)
}
@Test fun refreshingInstalledIdentityCannotMaskAnOngoingCheck() {
launch(UpdateUiState.UpToDate)
rule.runOnIdle { controller.showInstalled(InstalledVersion("1.1.1", 100001)) }
rule.mainClock.advanceTimeBy(320)
rule.runOnIdle { controller.show(UpdateUiState.Checking) }
rule.mainClock.advanceTimeBy(320)
rule.onNodeWithTag("update-progress").assertIsDisplayed()
}
@Test fun progressKeepsAutoCheckFocusAcrossByteAndReducedMotionChanges() {
launch(UpdateUiState.Checking)
val target = directory
if (target != null) {
repeat(113) { frame ->
rule.mainClock.advanceTimeBy(16)
capture("motion/checking-%03d".format(frame))
}
}
rule.runOnIdle { controller.show(UpdateUiState.Downloading(20_100_000, 47_800_000)) }
rule.mainClock.advanceTimeByFrame()
repeat(24) { frame -> rule.mainClock.advanceTimeBy(16); capture("motion/state-%03d".format(frame)) }
if (target != null) {
repeat(101) { frame -> rule.mainClock.advanceTimeBy(16); capture("motion/download-%03d".format(frame)) }
}
press(listOf(Key.DirectionDown))
rule.runOnIdle { controller.show(UpdateUiState.Downloading(31_000_000, 47_800_000)) }
rule.mainClock.advanceTimeBy(16)
assertTrue((shell.contentFocus as UpdateFocus).autoFocused)
rule.runOnIdle { reduced.value = true }
rule.mainClock.advanceTimeBy(96)
capture("motion/reduced-000")
rule.mainClock.advanceTimeBy(1600)
capture("motion/reduced-100")
assertTrue((shell.contentFocus as UpdateFocus).autoFocused)
}
}

View file

@ -21,7 +21,6 @@ import kotlinx.coroutines.CoroutineScope
import org.justinvisser.wolfskin.conductarr.CacheEntry
import org.justinvisser.wolfskin.conductarr.CacheStore
import org.justinvisser.wolfskin.conductarr.FileCacheStore
import org.justinvisser.wolfskin.conductarr.PersistentDns
/** Advanced-only endpoint and fixture controls contain no credentials. */
object ConductarrPreference {
@ -55,8 +54,8 @@ object ConductarrPreference {
object ConductarrModule {
@Provides
@Singleton
fun session(@ApplicationContext context: Context, @IoCoroutineScope scope: CoroutineScope, dns: PersistentDns): ConductarrSession =
ConductarrSession(ConductarrDiskCache(context), scope, dns)
fun session(@ApplicationContext context: Context, @IoCoroutineScope scope: CoroutineScope): ConductarrSession =
ConductarrSession(ConductarrDiskCache(context), scope)
}
/** Android may create noBackupFilesDir, so resolve it only during an IO cache operation. */

View file

@ -29,7 +29,6 @@ data class ConductarrIdentity(
class ConductarrSession(
private val cache: CacheStore,
private val disposalScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
private val dns: PersistentDns? = null,
) : Closeable {
private val mutableIdentity = MutableStateFlow<ConductarrIdentity?>(null)
val identity: StateFlow<ConductarrIdentity?> = mutableIdentity.asStateFlow()
@ -51,7 +50,7 @@ class ConductarrSession(
}
try {
val client = if (token.isNullOrBlank()) null else try {
ConductarrClient(baseUrl, token, PersonCacheStore(cache, person), timeouts = ClientTimeouts(3, 5, 8), dns = dns, onEvent = { event ->
ConductarrClient(baseUrl, token, PersonCacheStore(cache, person), timeouts = ClientTimeouts(3, 5, 8), onEvent = { event ->
if (event == ClientEvent.NEEDS_SIGN_IN) {
mutableIdentity.update {
if (generation == currentGeneration) it?.copy(operator = null, signedIn = false, error = ClientError.NeedsSignIn) else it

View file

@ -1,46 +0,0 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv
import android.content.Context
import com.github.damontecres.wholphin.data.MostRecentServer
import com.github.damontecres.wholphin.data.MostRecentServerProvider
import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Provider
import javax.inject.Singleton
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.justinvisser.wolfskin.conductarr.PersistentDns
/** Process warm-up never blocks application creation or serializes the two service lookups. */
@Singleton
class NetworkStartup @Inject constructor(
@param:ApplicationContext private val context: Context,
private val recent: Provider<MostRecentServerProvider>,
private val dns: PersistentDns,
@param:IoCoroutineScope private val scope: CoroutineScope,
) {
fun start() {
scope.launch {
val url = when (val server = recent.get().get()) {
is MostRecentServer.Server -> server.serverUrl
is MostRecentServer.ServerAndUser -> server.serverUrl
MostRecentServer.None -> null
}
warm(url)
}
scope.launch {
ConductarrPreference.initialize(context)
warm(ConductarrPreference.baseUrl)
}
}
private fun warm(url: String?) {
val host = url?.toHttpUrlOrNull()?.host ?: return
try { dns.lookup(host) } catch (_: Exception) {
// An offline launch must still render the saved people.
}
}
}

View file

@ -80,7 +80,6 @@ class WolfskinShellViewModel @Inject constructor(
val seeAll: WolfskinSeeAllLists,
val conductarr: ConductarrSession,
artwork: WolfskinArtwork,
val updates: org.justinvisser.wolfskin.tv.update.UpdateController,
) : ViewModel() {
init { artwork.configure(context) }
val backdropFlow: StateFlow<BackdropResult> = backdrop.backdropFlow
@ -196,7 +195,7 @@ class WolfskinShellViewModel @Inject constructor(
is ShellAction.Destination -> {
val target = if (action.route == "home") Destination.Home() else Destination.Wolfskin(action.route)
when {
action.route == "settings" || action.route == "updates" -> navigation.navigateTo(target)
action.route == "settings" -> navigation.navigateTo(target)
action.preservePushed && navigation.backStack.size > 1 -> navigation.backStack[0] = target
else -> navigation.replace(target)
}
@ -263,7 +262,6 @@ fun WolfskinApp(
{ shell, homeServer, homeUser, contentModifier -> HomeDestination(shell, homeServer, homeUser, contentModifier) },
) {
val identity by viewModel.conductarr.identity.collectAsStateWithLifecycle()
val installedVersion by viewModel.updates.installedVersion.collectAsStateWithLifecycle()
LaunchedEffect(server.id, user.id, user.accessToken, ConductarrPreference.baseUrl, ConductarrPreference.fixtures) { viewModel.load(server, user) }
androidx.lifecycle.compose.LifecycleEventEffect(androidx.lifecycle.Lifecycle.Event.ON_RESUME) { viewModel.onForeground(server, user) }
LaunchedEffect(identity?.signedIn, identity?.person) {
@ -305,13 +303,7 @@ fun WolfskinApp(
onBack = { if (navigationManager.backStack.last().fullScreen) navigationManager.goBack() else state.key(org.justinvisser.wolfskin.design.ShellKey.Back) },
entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator()),
entryProvider = { destination ->
NavEntry(
destination,
contentKey = "${destination}_${server.id}_${user.id}",
metadata = if (destination is Destination.Wolfskin && destination.route == "updates") {
org.justinvisser.wolfskin.tv.update.updatesRouteTransitions
} else emptyMap(),
) {
NavEntry(destination, contentKey = "${destination}_${server.id}_${user.id}") {
// Cached entries must not retain the launch shell after sign-in completes.
val state = LocalShellState.current
val discover = LocalDiscoverState.current
@ -337,15 +329,11 @@ fun WolfskinApp(
} else TitleDestination(route, state, preferences)
} else {
ShellSurface(state, handlesContentKeys = upstream == null,
settingsInstalledVersion = installedVersion.versionName,
overlay = { if (route == "discover" || route.startsWith("discover-")) DiscoverOverlay(discover) }) { contentModifier ->
when {
(upstream != null || destination is Destination.Home || route in LIBRARY_DESTINATIONS) && state.person.key != viewModel.signedInPerson -> ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", contentModifier)
upstream != null -> DestinationContent(upstream, preferences, viewModel::clearBackdrop, contentModifier)
destination is Destination.Home -> homeContent(state, server, user, contentModifier)
route == "updates" -> org.justinvisser.wolfskin.tv.update.UpdatesScreen(state, viewModel.updates,
onBack = { state.navFocused = false; navigationManager.goBack() },
onClassic = { navigationManager.navigateTo(handOffDestination("classic")) }, modifier = contentModifier)
route in LIBRARY_DESTINATIONS || route.startsWith("seeall/") ->
WolfskinLibraryContent(androidx.compose.ui.platform.LocalContext.current, route, state, user, "${server.id}:${user.id}", viewModel.libraries, navigationManager, viewModel.seeAll, contentModifier)
destination is Destination.Wolfskin -> DestinationContent(destination, preferences, viewModel::clearBackdrop, contentModifier)

View file

@ -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

View file

@ -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")

View file

@ -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")
}
}

View file

@ -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<AppPreferences>,
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
}

View file

@ -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<String?>(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) }
}
}
}

View file

@ -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<String>()
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 }
}

View file

@ -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<Boolean>
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<String>,
private val sdk: Int,
) : UpdateController {
private val mutableState = MutableStateFlow<UpdateUiState>(UpdateUiState.Idle)
private val mutableVersion = MutableStateFlow(platform.installed().version)
private val mutableRelease = MutableStateFlow<UpdateUiState.Available?>(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
}
}
}
}

View file

@ -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
}

View file

@ -1,53 +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<UpdateUiState>
val autoCheck: StateFlow<Boolean>
val installedVersion: StateFlow<InstalledVersion>
/** Candidate facts survive download/installer transitions and verified-file restoration. */
val release: StateFlow<UpdateUiState.Available?>
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,
}

View file

@ -1,79 +0,0 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv.update
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import org.justinvisser.wolfskin.design.ShellContentFocus
import org.justinvisser.wolfskin.design.ShellKey
/** Action focus survives progress emissions; release notes own every key until Back. */
class UpdateFocus(
private val controller: UpdateController,
private val back: () -> Unit,
private val classic: () -> Unit,
private val nav: () -> Unit,
private val scrollNotes: (Int) -> Unit,
) : ShellContentFocus {
override val cardKey: String? = null
var actionIndex by mutableIntStateOf(0)
private set
var autoFocused by mutableStateOf(false)
private set
var notesOpen by mutableStateOf(false)
private set
private var actions = emptyList<UpdateAction>()
private var previousState: Class<*>? = null
/** Only a changed operation may retire an action; progress cannot steal focus. */
fun update(state: UpdateUiState, value: List<UpdateAction>) {
if (previousState != state.javaClass) {
actionIndex = 0
notesOpen = false
if (state == UpdateUiState.InstallCancelled || state is UpdateUiState.Ready) autoFocused = false
}
previousState = state.javaClass
actions = value
actionIndex = actionIndex.coerceAtMost(actions.lastIndex.coerceAtLeast(0))
}
fun focusAction(index: Int) { actionIndex = index; autoFocused = false }
fun activate(index: Int) {
focusAction(index)
when (actions[index].command) {
UpdateCommand.Back -> back()
UpdateCommand.Check -> controller.check(manual = true)
UpdateCommand.Download -> controller.download()
UpdateCommand.Notes -> notesOpen = true
UpdateCommand.Cancel -> controller.cancel()
UpdateCommand.Install -> controller.install()
UpdateCommand.Retry -> controller.retry()
UpdateCommand.Classic -> classic()
}
}
fun toggleAuto() { autoFocused = true; controller.setAutoCheck(!controller.autoCheck.value) }
override fun key(key: ShellKey): Boolean {
if (notesOpen) {
when (key) {
ShellKey.Back -> notesOpen = false
ShellKey.Up -> scrollNotes(-1)
ShellKey.Down -> scrollNotes(1)
else -> Unit
}
return true
}
when (key) {
ShellKey.Back -> back()
ShellKey.Up -> if (autoFocused) autoFocused = false else nav()
ShellKey.Down -> autoFocused = true
ShellKey.Left -> if (!autoFocused) actionIndex = (actionIndex - 1).coerceAtLeast(0)
ShellKey.Right -> if (!autoFocused) actionIndex = (actionIndex + 1).coerceAtMost(actions.lastIndex.coerceAtLeast(0))
ShellKey.Ok -> if (autoFocused) toggleAuto() else if (actions.isNotEmpty()) activate(actionIndex)
}
return true
}
}

View file

@ -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>(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<Intent>("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()
}
}

View file

@ -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<UpdateAsset>,
) {
fun selectAsset(abis: List<String>, 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<UpdateManifest>(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)

View file

@ -1,13 +0,0 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv.update
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.togetherWith
import androidx.navigation3.ui.NavDisplay
/** Updates animates bounded content only; the retained nav never crossfades as a full scene. */
internal val updatesRouteTransitions =
NavDisplay.transitionSpec { (EnterTransition.None togetherWith ExitTransition.None).using(null) } +
NavDisplay.popTransitionSpec { (EnterTransition.None togetherWith ExitTransition.None).using(null) } +
NavDisplay.predictivePopTransitionSpec { (EnterTransition.None togetherWith ExitTransition.None).using(null) }

View file

@ -1,92 +0,0 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv.update
import java.text.NumberFormat
/** Screen commands remain explicit; no state transition implicitly installs an APK. */
enum class UpdateCommand { Back, Check, Download, Notes, Cancel, Install, Retry, Classic }
/** An action is a user command, not a focusable progress indicator. */
data class UpdateAction(val label: String, val command: UpdateCommand, val primary: Boolean = false)
/** Plain copy for the process controller's state; bytes and notes remain real controller data. */
data class UpdatePresentation(
val title: String = "",
val summary: String = "",
val paragraphs: List<String> = emptyList(),
val actions: List<UpdateAction>,
val progress: Boolean = false,
val fraction: Float? = null,
val byteProgress: String? = null,
)
/** Decimal megabytes match release sizes; unknown totals never create a percentage. */
fun formatUpdateBytes(bytes: Long): String = NumberFormat.getNumberInstance().apply {
minimumFractionDigits = 1
maximumFractionDigits = 1
}.format(bytes / 1_000_000.0) + " MB"
/** Metadata uses the controller's locale-formatted release date. */
fun updateMetadata(release: UpdateUiState.Available?): String = release?.let {
"${it.versionName} · ${formatUpdateBytes(it.bytes)} · ${it.dateText}"
}.orEmpty()
/** Every controller outcome is distinct from a successful compatible version comparison. */
fun presentUpdate(state: UpdateUiState, release: UpdateUiState.Available?, updatedVersion: String? = null): UpdatePresentation {
val back = UpdateAction("Back", UpdateCommand.Back)
val notes = UpdateAction("Release notes", UpdateCommand.Notes)
val retry = UpdateAction("Retry", UpdateCommand.Retry, true)
val metadata = updateMetadata(release)
return when (state) {
UpdateUiState.Idle -> updatedVersion?.let(::updatedPresentation)
?: UpdatePresentation(actions = listOf(UpdateAction("Check for updates", UpdateCommand.Check, true)))
UpdateUiState.Checking -> UpdatePresentation(actions = listOf(back), progress = true)
UpdateUiState.UpToDate -> updatedVersion?.let(::updatedPresentation)
?: UpdatePresentation("Up to date", "No newer release for this TV.",
actions = listOf(UpdateAction("Check again", UpdateCommand.Check, true)))
UpdateUiState.NoReleasePublished -> UpdatePresentation("No release published", "No update has been published for this TV yet.",
actions = listOf(UpdateAction("Check again", UpdateCommand.Check, true)))
is UpdateUiState.Available -> UpdatePresentation("Update available", updateMetadata(state),
state.notes.lineSequence().filter { it.isNotBlank() }.take(3).toList(),
listOf(UpdateAction("Download", UpdateCommand.Download, true), notes))
is UpdateUiState.Downloading -> {
val total = state.bytesTotal?.takeIf { it > 0 }
val fraction = total?.let { (state.bytesDone.toDouble() / it).coerceIn(0.0, 1.0).toFloat() }
val bytes = total?.let { "${formatUpdateBytes(state.bytesDone).removeSuffix(" MB")} / ${formatUpdateBytes(it)}" }
?: formatUpdateBytes(state.bytesDone)
UpdatePresentation(fraction?.let { "${(it * 100).toInt()}%" }.orEmpty(), metadata,
actions = listOf(UpdateAction("Cancel download", UpdateCommand.Cancel)), progress = true, fraction = fraction, byteProgress = bytes)
}
is UpdateUiState.Ready -> UpdatePresentation("Ready to install", metadata.ifEmpty { state.versionName },
listOf("Verified · your sign-ins and settings stay."), listOf(UpdateAction("Install update", UpdateCommand.Install, true), notes))
UpdateUiState.PermissionNeeded -> UpdatePresentation("Allow updates on this TV", "Enable Wolfskin in Install unknown apps, then return.",
actions = listOf(UpdateAction("Open TV settings", UpdateCommand.Install, true), UpdateAction("Not now", UpdateCommand.Cancel)))
UpdateUiState.HandingOff -> UpdatePresentation("Continue in Android", "Confirm the update on the system screen.",
listOf("Wolfskin closes during installation.", "Choose Open, or reopen Wolfskin from the launcher."), listOf(UpdateAction("Back", UpdateCommand.Cancel)))
UpdateUiState.InstallCancelled -> UpdatePresentation("Install cancelled", metadata,
listOf("Verified · your sign-ins and settings stay."), listOf(UpdateAction("Try install again", UpdateCommand.Install, true), notes))
is UpdateUiState.Failed -> UpdatePresentation(failureTitle(state.kind, state.operation), state.message, actions = listOf(retry, back))
UpdateUiState.Offline -> UpdatePresentation("No network", "Reconnect this TV, then try again.",
actions = listOf(UpdateAction("Try again", UpdateCommand.Retry, true)))
UpdateUiState.NoInstaller -> UpdatePresentation("This TV cannot open an installer", "Android’s install confirmation is unavailable.",
listOf("The download is kept. Classic settings may offer the same system limitation."),
listOf(retry, UpdateAction("Classic settings (trial)", UpdateCommand.Classic)))
}
}
private fun updatedPresentation(version: String) = UpdatePresentation(
"Updated to $version", "Sign-ins and settings kept.",
actions = listOf(UpdateAction("Check again", UpdateCommand.Check, true)),
)
private fun failureTitle(kind: UpdateFailure, operation: UpdateOperation): String {
if (operation == UpdateOperation.Install) return "Install failed"
return when (kind) {
UpdateFailure.Network, UpdateFailure.HttpRefused, UpdateFailure.InsufficientStorage ->
if (operation == UpdateOperation.Check) "Check failed" else "Download failed"
UpdateFailure.InvalidMetadata ->
if (operation == UpdateOperation.Check) "Update unavailable" else "Verification failed"
UpdateFailure.UnsupportedAbi -> "Update unavailable"
UpdateFailure.ChecksumMismatch, UpdateFailure.WrongPackage, UpdateFailure.WrongSigner, UpdateFailure.LowerVersionCode -> "Verification failed"
}
}

View file

@ -1,58 +0,0 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv.update
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.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.ProgressBarRangeInfo
import androidx.compose.ui.semantics.progressBarRangeInfo
import androidx.compose.ui.semantics.semantics
import org.justinvisser.wolfskin.design.reducedMotion
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M
import org.justinvisser.wolfskin.design.WolfskinTokens.Update as U
/** Draw-only bounded track: changing bytes never moves logical or Android focus. */
@Composable
fun UpdateProgress(fraction: Float?, modifier: Modifier = Modifier) {
val reduced = reducedMotion()
val phase = if (reduced) 0f else {
val transition = rememberInfiniteTransition(label = "update-progress")
val animated by transition.animateFloat(0f, 1f,
infiniteRepeatable(tween(if (fraction == null) M.updateScan else M.updateSheen,
easing = if (fraction == null) M.breatheEase else M.linear), RepeatMode.Restart), label = "highlight")
animated
}
Canvas(modifier.fillMaxWidth().height(U.progressHeight).testTag("update-progress")
.semantics { progressBarRangeInfo = fraction?.let { ProgressBarRangeInfo(it, 0f..1f) } ?: ProgressBarRangeInfo.Indeterminate }) {
val radius = CornerRadius(U.progressRadius.toPx())
drawRoundRect(C.surface3, cornerRadius = radius)
val width = size.width * (fraction ?: U.scanFraction)
val left = if (fraction != null || reduced) 0f else width * (-1f + (U.scanEnd + 1f) * phase)
clipRect {
drawRoundRect(C.accent, Offset(left, 0f), Size(width, size.height), radius)
if (!reduced && fraction != null && width > 0f) {
clipRect(0f, 0f, width, size.height) {
val start = width * (-1f + 2f * phase)
drawRect(Brush.horizontalGradient(listOf(Color.Transparent, Color.White.copy(alpha = U.sheenAlpha), Color.Transparent),
startX = start, endX = start + width), size = Size(width, size.height))
}
}
}
}
}

View file

@ -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<Call?>()
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<Response> { 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<String>, 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<String>, 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 }
}

View file

@ -1,202 +0,0 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv.update
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.tv.material3.Text
import kotlinx.coroutines.launch
import org.justinvisser.wolfskin.design.Avatar
import org.justinvisser.wolfskin.design.CssColumn
import org.justinvisser.wolfskin.design.PickerButton
import org.justinvisser.wolfskin.design.ShellState
import org.justinvisser.wolfskin.design.WolfskinTokens
import org.justinvisser.wolfskin.design.exactLineBox
import org.justinvisser.wolfskin.design.focusScale
import org.justinvisser.wolfskin.design.motionDuration
import org.justinvisser.wolfskin.design.reducedMotion
import org.justinvisser.wolfskin.design.resolveUpdateAvatarColor
import org.justinvisser.wolfskin.design.rowReveal
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M
import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S
import org.justinvisser.wolfskin.design.UpdateTypography as T
import org.justinvisser.wolfskin.design.WolfskinTokens.Update as U
/** Controller lifetime belongs to the process, while navigation owns this screen's focus. */
@Composable
fun UpdatesScreen(shell: ShellState, controller: UpdateController, onBack: () -> Unit, onClassic: () -> Unit, modifier: Modifier = Modifier) {
val state by controller.state.collectAsStateWithLifecycle()
val installed by controller.installedVersion.collectAsStateWithLifecycle()
val release by controller.release.collectAsStateWithLifecycle()
val autoCheck by controller.autoCheck.collectAsStateWithLifecycle()
val initialInstalledCode = remember { installed.versionCode }
val updatedVersion = installed.versionName.takeIf { installed.versionCode > initialInstalledCode }
val presentation = remember(state, release, updatedVersion) {
presentUpdate(state, release, updatedVersion.takeIf { state == UpdateUiState.Idle || state == UpdateUiState.UpToDate })
}
val scroll = rememberScrollState()
val scope = rememberCoroutineScope()
val density = LocalDensity.current
val reduced = reducedMotion()
val scrollDuration by rememberUpdatedState(motionDuration(M.scroll, reduced))
val focus = remember(controller, shell) {
UpdateFocus(controller, onBack, onClassic, { shell.navFocused = true; shell.navIndex = shell.currentTab }) { direction ->
scope.launch { scroll.animateScrollBy(with(density) { U.notesScrollStep.toPx() } * direction,
tween(scrollDuration, easing = M.easeOut)) }
}
}
LaunchedEffect(state, presentation.actions) { focus.update(state, presentation.actions) }
LaunchedEffect(controller) { if (controller.state.value == UpdateUiState.Idle) controller.check(manual = true) }
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { controller.onResume() }
DisposableEffect(shell, focus) {
shell.contentFocus = focus
onDispose { if (shell.contentFocus === focus) shell.contentFocus = null; shell.contentOverlayVisible = false }
}
LaunchedEffect(focus.notesOpen) { shell.contentOverlayVisible = focus.notesOpen }
Box(modifier.fillMaxSize().testTag("updates-screen")) {
Row(Modifier.offset(L.safeSide, U.headerTop).height(U.headerHeight),
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.settingsHeaderGap)) {
Avatar(shell.person, S.settingsHeaderAvatar, T.avatarLetterLibHeader, letterColor = C.muted, backgroundColor = resolveUpdateAvatarColor(shell.person))
Text("Updates", Modifier.alignByBaseline().exactLineBox(T.screenTitle), style = T.screenTitle, color = C.fg)
Text("Installed ${installed.versionName}", Modifier.alignByBaseline(), style = T.nav.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Normal), color = C.muted)
}
Text("Back · Settings", Modifier.align(Alignment.TopEnd).padding(end = L.safeSide).offset(y = U.backTop), style = T.label.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Normal), color = C.muted)
if (focus.notesOpen) {
NotesPanel(release, scroll)
} else {
UpdatePanel(presentation, focus, !shell.navFocused, state.javaClass)
AutoCheckRow(autoCheck, focus.autoFocused && !shell.navFocused, Modifier.offset(L.safeSide, U.autoTop), focus::toggleAuto)
}
}
}
@Composable
private fun UpdatePanel(presentation: UpdatePresentation, focus: UpdateFocus, focused: Boolean, identity: Any) {
val shape = RoundedCornerShape(U.radius)
CssColumn(Modifier.offset(L.safeSide, U.contentTop).width(U.width).rowReveal(identity)
.background(C.surface, shape).border(U.border, C.border, shape).padding(U.padding + U.border)) {
if (presentation.title.isNotEmpty()) {
Text(presentation.title, Modifier.exactLineBox(T.heading), style = T.heading, color = C.fg)
Spacer(Modifier.height(U.titleBottom))
}
if (presentation.summary.isNotEmpty()) {
Text(presentation.summary, Modifier.exactLineBox(T.body), style = T.body, color = C.muted)
if (presentation.paragraphs.isNotEmpty()) Spacer(Modifier.height(U.paragraphBottom))
}
presentation.paragraphs.forEachIndexed { index, paragraph ->
Text(paragraph, Modifier.exactLineBox(T.body), style = T.body, color = C.muted)
if (index != presentation.paragraphs.lastIndex) Spacer(Modifier.height(U.paragraphBottom))
}
if (presentation.progress) {
Spacer(Modifier.height(U.progressTop))
UpdateProgress(presentation.fraction)
presentation.byteProgress?.let {
Spacer(Modifier.height(U.progressBottom))
Text(it, Modifier.exactLineBox(T.body), style = T.body, color = C.muted)
}
}
Row(Modifier.padding(top = U.actionsTop), horizontalArrangement = Arrangement.spacedBy(U.actionGap)) {
presentation.actions.forEachIndexed { index, action ->
Box(Modifier.testTag("update-action-${action.command}")) {
PickerButton(action.label, focused && !focus.autoFocused && focus.actionIndex == index, action.primary,
textStyle = T.button, onFocus = { focus.focusAction(index) }) { focus.activate(index) }
}
}
}
}
}
@Composable
private fun AutoCheckRow(enabled: Boolean, focused: Boolean, modifier: Modifier, onToggle: () -> Unit) {
val shape = RoundedCornerShape(WolfskinTokens.Radius.listRow)
Row(modifier.size(L.settingsRowWidth, L.settingsRowHeight).testTag("update-auto-check")
.semantics { role = Role.Switch; stateDescription = if (enabled) "On" else "Off" }
.focusScale(focused, WolfskinTokens.Focus.scaleRow)
.background(if (focused) C.fg else C.listRowRest, shape).clickable(onClick = onToggle)
.padding(horizontal = S.settingsRowPadding), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text("Auto-check for updates", Modifier.exactLineBox(T.listTitle), style = T.listTitle, color = if (focused) C.onFocus else C.fg)
Text("${if (enabled) "On" else "Off"} · checks only, never installs", Modifier.exactLineBox(T.caption), style = T.caption, color = if (focused) C.onFocus else C.muted)
}
Box(Modifier.size(U.toggleWidth, U.toggleHeight).background(if (focused || !enabled) C.surface3 else C.accentSoft, CircleShape)) {
Box(Modifier.align(if (enabled) Alignment.CenterEnd else Alignment.CenterStart).padding(horizontal = U.toggleInset)
.size(U.toggleThumb).background(if (enabled) C.accentText else C.muted, CircleShape))
}
}
}
@Composable
private fun NotesPanel(release: UpdateUiState.Available?, scroll: androidx.compose.foundation.ScrollState) {
val shape = RoundedCornerShape(U.radius)
val paragraphs = remember(release?.notes) { release?.notes?.lineSequence()?.filter { it.isNotBlank() }?.toList().orEmpty() }
val notesStyle = T.nav.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Normal)
CssColumn(Modifier.offset(L.safeSide, U.contentTop).size(U.width, U.notesHeight).testTag("update-notes")
.drawWithCache {
val ring = WolfskinTokens.Focus.ringControlRing.toPx() / 2f
val radius = U.radius.toPx()
val inset = U.border.toPx() + ring / 2f
onDrawWithContent {
drawContent()
drawRoundRect(C.accent, Offset(-ring / 2f, -ring / 2f), Size(size.width + ring, size.height + ring),
CornerRadius(radius + ring / 2f), style = Stroke(ring))
drawRoundRect(C.accent, Offset(inset, inset), Size(size.width - inset * 2, size.height - inset * 2),
CornerRadius(radius - inset), style = Stroke(ring))
}
}
.background(C.surface, shape).border(U.border, C.border, shape)
.verticalScroll(scroll).padding(U.padding + U.border)) {
Text(updateMetadata(release), Modifier.exactLineBox(T.stateTitle), style = T.stateTitle.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Bold), color = C.fg)
Spacer(Modifier.height(U.notesListTop))
paragraphs.forEachIndexed { index, paragraph ->
Row(Modifier.padding(start = U.notesMarkerOffset).exactLineBox(notesStyle)) {
Text("•", Modifier.width(U.notesMarkerWidth).exactLineBox(notesStyle), style = notesStyle, color = C.muted)
Text(paragraph.removePrefix("- "), Modifier.exactLineBox(notesStyle), style = notesStyle, color = C.muted)
}
if (index != paragraphs.lastIndex) Spacer(Modifier.height(U.notesGap))
}
Spacer(Modifier.height(U.notesListBottom))
Text("Sign-in stays on this TV. Android asks before installing.", Modifier.exactLineBox(T.body), style = T.body, color = C.muted)
}
}

View file

@ -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<String>)
/** 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<String>, 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()
}
}
}

View file

@ -67,7 +67,6 @@ class ConductarrClient(
timeouts: ClientTimeouts = ClientTimeouts(),
private val nowMillis: () -> Long = System::currentTimeMillis,
private val onEvent: (ClientEvent) -> Unit = {},
dns: PersistentDns? = null,
) : Closeable {
private val base = baseUrl.toHttpUrl()
private val scope: String
@ -75,7 +74,6 @@ class ConductarrClient(
private val cacheIdentityLock = Any()
private val catalogueReads = Semaphore(2)
private val http = OkHttpClient.Builder()
.apply { if (dns != null) DnsConnectionRetry.configure(this, dns) }
.connectTimeout(timeouts.connectSeconds, TimeUnit.SECONDS)
.readTimeout(timeouts.readSeconds, TimeUnit.SECONDS)
.callTimeout(timeouts.callSeconds, TimeUnit.SECONDS)

View file

@ -1,60 +0,0 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr
import java.io.IOException
import java.net.InetSocketAddress
import java.net.Proxy
import java.util.concurrent.ConcurrentHashMap
import okhttp3.Call
import okhttp3.EventListener
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Protocol
import okhttp3.Response
/** Retry once with fresh DNS only if connection establishment failed before any HTTP headers were sent. */
class DnsConnectionRetry(private val dns: PersistentDns) : Interceptor, EventListener.Factory {
private class Attempt {
@Volatile var connectionFailed = false
@Volatile var headersSent = false
}
private val attempts = ConcurrentHashMap<Call, Attempt>()
override fun create(call: Call): EventListener {
val attempt = Attempt()
attempts[call] = attempt
return object : EventListener() {
override fun connectFailed(call: Call, inetSocketAddress: InetSocketAddress, proxy: Proxy, protocol: Protocol?, ioe: IOException) {
attempt.connectionFailed = true
}
override fun requestHeadersStart(call: Call) { attempt.headersSent = true }
override fun callEnd(call: Call) { attempts.remove(call) }
override fun callFailed(call: Call, ioe: IOException) { attempts.remove(call) }
}
}
override fun intercept(chain: Interceptor.Chain): Response {
// OkHttp deliberately disables event listeners on its derived WebSocket client.
val attempt = attempts[chain.call()] ?: return chain.proceed(chain.request())
return try {
chain.proceed(chain.request())
} catch (failure: IOException) {
if (!attempt.connectionFailed || attempt.headersSent || chain.call().isCanceled()) throw failure
try {
dns.lookupFresh(chain.request().url.host)
} catch (lookupFailure: IOException) {
lookupFailure.addSuppressed(failure)
throw lookupFailure
}
chain.proceed(chain.request())
}
}
companion object {
/** Install the same resolver and its pre-request recovery on a separately owned client. */
fun configure(builder: OkHttpClient.Builder, dns: PersistentDns): OkHttpClient.Builder {
val retry = DnsConnectionRetry(dns)
return builder.dns(dns).eventListenerFactory(retry).addInterceptor(retry)
}
}
}

View file

@ -1,142 +0,0 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr
import java.io.File
import java.io.IOException
import java.net.InetAddress
import java.net.UnknownHostException
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executor
import java.util.concurrent.Executors
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import kotlinx.serialization.encodeToString
import okhttp3.Dns
/** Last-known system answers stay usable for one day while a single background lookup refreshes them. */
class PersistentDns(
private val file: () -> File,
private val resolver: Dns = Dns.SYSTEM,
private val executor: Executor = Executors.newFixedThreadPool(2) { task -> Thread(task, "wolfskin-dns").apply { isDaemon = true } },
private val nowMillis: () -> Long = System::currentTimeMillis,
) : Dns {
private class Host {
@Volatile var answer: Answer? = null
@Volatile var refreshAfter = 0L
var refreshing = false
val resolutionLock = Any()
}
@Serializable
private data class Answer(
val addresses: List<String>,
val savedAtMillis: Long,
@Transient val resolvedAddresses: List<InetAddress> = decodeAddresses(addresses),
)
private val hosts = ConcurrentHashMap<String, Host>()
private val diskLock = Any()
@Volatile private var loaded = false
override fun lookup(hostname: String): List<InetAddress> {
load()
val host = hosts.computeIfAbsent(hostname) { Host() }
val saved = host.answer
if (saved != null && nowMillis() - saved.savedAtMillis in 0..MAX_STALENESS_MILLIS) {
refresh(hostname, host)
return saved.resolvedAddresses
}
return synchronized(host.resolutionLock) {
val current = host.answer
if (current != null && current !== saved && nowMillis() - current.savedAtMillis in 0..MAX_STALENESS_MILLIS) {
current.resolvedAddresses
} else resolve(hostname, host)
}
}
/** A failed connection bypasses the cached answer and any completed background refresh. */
fun lookupFresh(hostname: String): List<InetAddress> {
load()
val host = hosts.computeIfAbsent(hostname) { Host() }
return synchronized(host.resolutionLock) {
host.answer = null
resolve(hostname, host)
}
}
private fun refresh(hostname: String, host: Host) {
synchronized(host) {
if (host.refreshing || nowMillis() < host.refreshAfter) return
host.refreshing = true
}
executor.execute {
try {
synchronized(host.resolutionLock) { resolve(hostname, host) }
} catch (_: Exception) {
// Failed refreshes do not extend the lifetime of the last successful answer.
host.refreshAfter = nowMillis() + FAILURE_BACKOFF_MILLIS
} finally {
synchronized(host) { host.refreshing = false }
}
}
}
private fun resolve(hostname: String, host: Host): List<InetAddress> {
val addresses = resolver.lookup(hostname)
if (addresses.isEmpty()) throw UnknownHostException(hostname)
host.answer = Answer(addresses.map { it.hostAddress!! }, nowMillis(), addresses)
host.refreshAfter = nowMillis() + REFRESH_INTERVAL_MILLIS
persist()
return addresses
}
private fun load() {
if (loaded) return
synchronized(diskLock) {
if (loaded) return
try {
val saved = file()
if (saved.isFile && saved.length() <= MAX_FILE_BYTES) {
val entries = WireJson.decodeFromString<Map<String, Answer>>(saved.readText())
require(entries.size <= MAX_HOSTS)
entries.forEach { (name, answer) -> hosts[name] = Host().apply { this.answer = answer } }
}
} catch (_: IOException) {
// A missing or damaged hint file cannot prevent normal system resolution.
} catch (_: IllegalArgumentException) {
// Serialization and literal-validation errors invalidate the entire hint file.
}
loaded = true
}
}
private fun persist() = synchronized(diskLock) {
try {
val entries = hosts.entries.mapNotNull { (name, host) -> host.answer?.let { name to it } }
.sortedByDescending { it.second.savedAtMillis }.take(MAX_HOSTS).toMap()
val target = file()
target.parentFile?.mkdirs()
val temporary = File(target.path + ".tmp")
temporary.writeText(WireJson.encodeToString(entries))
if (!temporary.renameTo(target)) temporary.delete()
} catch (_: IOException) {
// The in-memory answer remains usable when private storage is unavailable.
}
}
companion object {
const val MAX_STALENESS_MILLIS = 24 * 60 * 60 * 1000L
private const val REFRESH_INTERVAL_MILLIS = 60_000L
private const val FAILURE_BACKOFF_MILLIS = 2_000L
private const val MAX_HOSTS = 32
private const val MAX_ADDRESSES = 32
private const val MAX_FILE_BYTES = 64 * 1024L
private fun decodeAddresses(addresses: List<String>): List<InetAddress> {
require(addresses.isNotEmpty() && addresses.size <= MAX_ADDRESSES)
return addresses.map { address ->
// Numeric literals only: decoding the hint file must never perform DNS.
require(address.contains(':') || address.matches(Regex("[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+")))
InetAddress.getByName(address)
}
}
}
}

View file

@ -2,10 +2,6 @@
package org.justinvisser.wolfskin.conductarr
import java.nio.file.Files
import java.io.File
import java.net.InetAddress
import java.util.concurrent.Executor
import okhttp3.Dns
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.runBlocking
@ -18,24 +14,13 @@ fun main() = runBlocking {
try {
MockWebServer().use { server ->
server.start()
val baseUrl = server.url("/").newBuilder().host("localhost").build().toString()
var resolutions = 0
val dead = InetAddress.getByName("127.0.0.2")
val live = InetAddress.getByName("127.0.0.1")
val dns = PersistentDns({ File(directory, "dns.json") }, Dns {
resolutions++
if (resolutions == 1) listOf(dead) else listOf(live)
}, Executor { it.run() })
check(dns.lookup("localhost") == listOf(dead))
val baseUrl = server.url("/").toString()
server.enqueue(MockResponse.Builder().body(PROFILES_JSON).addHeader("ETag", "\"smoke\"").build())
ConductarrClient(baseUrl, TEST_TOKEN, FileCacheStore(directory), dns = dns).use { client ->
ConductarrClient(baseUrl, TEST_TOKEN, FileCacheStore(directory)).use { client ->
val live = client.read(TvRoutes.profiles()).toList().single() as ReadResult.Value
check(live.saved.value!!.profiles!!.single().name == "Justin")
check(resolutions == 2)
println("DNS SMOKE: cached 127.0.0.2 refused -> fresh 127.0.0.1 -> profile Justin; requests=${server.requestCount}, resolutions=$resolutions")
}
val reopened = PersistentDns({ File(directory, "dns.json") }, Dns { listOf(live) }, Executor { it.run() })
ConductarrClient(baseUrl, TEST_TOKEN, FileCacheStore(directory), dns = reopened).use { client ->
ConductarrClient(baseUrl, TEST_TOKEN, FileCacheStore(directory)).use { client ->
val saved = client.read(TvRoutes.profiles(), ReadTrigger.CACHE_ONLY).first() as ReadResult.Value
check(saved.saved.fromDisk)
server.enqueue(MockResponse.Builder().code(304).build())

View file

@ -1,68 +0,0 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr
import java.net.InetAddress
import java.util.concurrent.CompletableFuture
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
import mockwebserver3.MockResponse
import mockwebserver3.MockWebServer
import okhttp3.Dispatcher
import okhttp3.Dns
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
/** OkHttp's listener-free WebSocket client must retain a working upgrade and message path. */
class DnsConnectionRetryTest {
@get:Rule val directory = TemporaryFolder()
@Test fun derivedClientOpensWebSocketWithoutAnUncaughtDispatcherException() {
val opened = CompletableFuture<Int>()
val message = CompletableFuture<String>()
val uncaught = AtomicReference<Throwable?>()
val executor = Executors.newCachedThreadPool { task ->
Thread(task, "dns-websocket-test").apply {
uncaughtExceptionHandler = Thread.UncaughtExceptionHandler { _, failure ->
uncaught.set(failure)
opened.completeExceptionally(failure)
message.completeExceptionally(failure)
}
}
}
MockWebServer().use { server ->
server.enqueue(MockResponse.Builder().webSocketUpgrade(object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) { webSocket.send("ready") }
}).build())
server.start()
val dns = PersistentDns({ directory.root.resolve("dns.json") }, Dns { listOf(InetAddress.getByName("127.0.0.1")) })
val client = DnsConnectionRetry.configure(OkHttpClient.Builder(), dns)
.dispatcher(Dispatcher(executor)).build().newBuilder().build()
val socket = client.newWebSocket(Request.Builder().url(server.url("/events")).build(), object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) { opened.complete(response.code) }
override fun onMessage(webSocket: WebSocket, text: String) { message.complete(text) }
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
opened.completeExceptionally(t)
message.completeExceptionally(t)
}
})
try {
assertEquals(101, opened.get(5, TimeUnit.SECONDS).toInt())
assertEquals("ready", message.get(5, TimeUnit.SECONDS))
assertNull(uncaught.get())
println("WEBSOCKET SMOKE: derived cached-DNS client opened HTTP 101, received ready, no uncaught dispatcher exception")
} finally {
socket.cancel()
client.connectionPool.evictAll()
executor.shutdownNow()
}
}
}
}

View file

@ -1,160 +0,0 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr
import java.io.File
import java.net.InetAddress
import java.net.Proxy
import java.net.UnknownHostException
import java.util.concurrent.Executor
import java.util.concurrent.TimeUnit
import mockwebserver3.MockResponse
import mockwebserver3.MockWebServer
import okhttp3.ConnectionPool
import okhttp3.Dns
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
/** Fake time and queued refreshes prove stale answers, disk hints and dead-address recovery. */
class PersistentDnsTest {
@get:Rule val directory = TemporaryFolder()
private var now = 1_000_000L
private val queued = ArrayDeque<Runnable>()
private val background = Executor { queued.add(it) }
private val old = InetAddress.getByName("127.0.0.2")
private val fresh = InetAddress.getByName("127.0.0.1")
private var answer = listOf(old)
private var lookups = 0
private val resolver = Dns { lookups++; answer }
private val file get() = File(directory.root, "dns.json")
private fun cache(resolve: Dns = resolver) = PersistentDns({ file }, resolve, background, { now })
@Test fun staleAnswerReturnsBeforeBackgroundRefreshAndNextLookupUsesChangedIp() {
val dns = cache()
assertEquals(listOf(old), dns.lookup("jellyfin.test"))
now += 120_000
answer = listOf(fresh)
assertEquals(listOf(old), dns.lookup("jellyfin.test"))
assertEquals(1, lookups)
assertEquals(listOf(old), dns.lookup("jellyfin.test"))
assertEquals(1, queued.size)
queued.removeFirst().run()
assertEquals(listOf(fresh), dns.lookup("jellyfin.test"))
assertEquals(2, lookups)
}
@Test fun answersOlderThanOneDayCannotBeServedEvenWhenRefreshFails() {
val dns = cache()
dns.lookup("jellyfin.test")
now += PersistentDns.MAX_STALENESS_MILLIS + 1
val expired = cache(Dns { throw UnknownHostException() })
assertThrows(UnknownHostException::class.java) { expired.lookup("jellyfin.test") }
}
@Test fun persistedAnswersRetainTimestampAndSystemAddressOrdering() {
answer = listOf(InetAddress.getByName("::1"), fresh)
cache().lookup("jellyfin.test")
now += 120_000
val reopened = cache(Dns { error("Disk answer must return before resolving") })
assertEquals(answer, reopened.lookup("jellyfin.test"))
assertEquals(1, queued.size)
now += PersistentDns.MAX_STALENESS_MILLIS
assertThrows(UnknownHostException::class.java) {
cache(Dns { throw UnknownHostException() }).lookup("jellyfin.test")
}
}
@Test fun corruptOrNonnumericDiskAnswersAreIgnored() {
for (contents in listOf("not json", "{\"jellyfin.test\":{\"addresses\":[\"not-an-ip.test\"],\"savedAtMillis\":1000000}}")) {
file.writeText(contents)
answer = listOf(fresh)
assertEquals(listOf(fresh), cache().lookup("jellyfin.test"))
}
assertEquals(2, lookups)
}
@Test fun negativeAnswersDoNotDelayRecoveryAndFailedRefreshDoesNotExtendStaleness() {
var offline = true
val dns = cache(Dns { if (offline) throw UnknownHostException(); listOf(fresh) })
assertThrows(UnknownHostException::class.java) { dns.lookup("jellyfin.test") }
offline = false
assertEquals(listOf(fresh), dns.lookup("jellyfin.test"))
offline = true
now += 120_000
assertEquals(listOf(fresh), dns.lookup("jellyfin.test"))
queued.removeFirst().run()
assertEquals(listOf(fresh), dns.lookup("jellyfin.test"))
assertTrue(queued.isEmpty())
now += 2_001
dns.lookup("jellyfin.test")
assertEquals(1, queued.size)
}
@Test fun unexpectedRefreshExceptionKeepsTheLastAnswerAndAllowsAnotherRefresh() {
var failing = false
val dns = cache(Dns { if (failing) throw IllegalStateException("resolver unavailable"); answer })
dns.lookup("jellyfin.test")
now += 120_000
failing = true
assertEquals(listOf(old), dns.lookup("jellyfin.test"))
queued.removeFirst().run()
now += 2_001
failing = false
answer = listOf(fresh)
assertEquals(listOf(old), dns.lookup("jellyfin.test"))
queued.removeFirst().run()
assertEquals(listOf(fresh), dns.lookup("jellyfin.test"))
}
@Test fun failedCachedAddressForcesFreshResolutionAndRetriesBeforeSendingPost() {
MockWebServer().use { server ->
server.start()
val dns = cache()
dns.lookup("jellyfin.test")
answer = listOf(fresh)
server.enqueue(MockResponse.Builder().body("new address").build())
val client = DnsConnectionRetry.configure(OkHttpClient.Builder(), dns)
.proxy(Proxy.NO_PROXY).retryOnConnectionFailure(false).connectTimeout(1, TimeUnit.SECONDS)
.connectionPool(ConnectionPool(0, 1, TimeUnit.MILLISECONDS)).build()
try {
val url = server.url("/").newBuilder().host("jellyfin.test").build()
client.newCall(Request.Builder().url(url).post("body".toRequestBody()).build()).execute().use {
assertEquals("new address", it.body.string())
}
assertEquals(2, lookups)
assertEquals(1, server.requestCount)
assertEquals(listOf(fresh), dns.lookup("jellyfin.test"))
} finally {
client.connectionPool.evictAll()
client.dispatcher.executorService.shutdown()
}
}
}
@Test fun backgroundIpChangeIsUsedByTheNextHttpCall() {
MockWebServer().use { server ->
server.start()
val dns = cache()
dns.lookup("jellyfin.test")
now += 120_000
answer = listOf(fresh)
assertEquals(listOf(old), dns.lookup("jellyfin.test"))
queued.removeFirst().run()
server.enqueue(MockResponse.Builder().body("refreshed").build())
val client = DnsConnectionRetry.configure(OkHttpClient.Builder(), dns).proxy(Proxy.NO_PROXY).build()
try {
val url = server.url("/").newBuilder().host("jellyfin.test").build()
client.newCall(Request.Builder().url(url).build()).execute().use { assertEquals("refreshed", it.body.string()) }
assertEquals(2, lookups)
assertEquals(1, server.requestCount)
} finally {
client.connectionPool.evictAll()
client.dispatcher.executorService.shutdown()
}
}
}
}

View file

@ -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.

View file

@ -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

View file

@ -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'<th>{c}</th>' for c in head)
b = ''.join('<tr>' + ''.join(f'<td>{c}</td>' for c in r) + '</tr>' for r in rows)
return f'<div class="tw"><table class="{cls}"><thead><tr>{h}</tr></thead><tbody>{b}</tbody></table></div>'
def pair(pid, scene, before, after_title, lead, note='', variants=False, before2=None):
vids = ''.join(f'<div class="pane"><p class="lab">{lab}</p><div class="sbox"><video src="tv-motion-speed/{src}.webm" muted loop playsinline autoplay preload="auto"></video></div></div>'
for src, lab in ([before] + ([before2] if before2 else [])))
var = f'<div class="tabs" data-variants="{pid}"></div>' if variants else ''
return f'''<div class="pair"><div class="ph"><button type="button" class="replay">Replay together</button>{f'<span class="muted small">{note}</span>' if note else ''}</div>
<div class="panes">{vids}<div class="pane"><p class="lab">{after_title}</p>{var}<div class="sbox" id="{pid}" data-scene="{scene}" data-lead="{lead}"></div></div></div></div>'''
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'''<!doctype html><html lang="en" data-lavish-annotate="off"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="dark"><title>Wolfskin TV: faster, quieter, smoother</title><style>
:root{{color-scheme:dark;--bg:#07070a;--panel:#13131a;--line:rgba(255,255,255,.1);--fg:#f4f4f6;--muted:rgba(244,244,246,.74);--acc:oklch(78% 0.14 305);--ok:#7ed6a0;--warn:#ffc46b;--bad:#ff8a7a}}
*{{box-sizing:border-box}}html{{-webkit-text-size-adjust:100%}}body{{margin:0;background:var(--bg);color:var(--fg);font:16px/1.5 system-ui,-apple-system,Segoe UI,sans-serif}}
main{{max-width:1400px;margin:0 auto;padding:16px 14px 120px}}h1{{font-size:28px;line-height:1.2;margin:6px 0 6px}}h2{{font-size:21px;margin:34px 0 8px;color:var(--acc);border-bottom:1px solid var(--line);padding-bottom:4px}}
h3{{font-size:17px;margin:20px 0 6px}}a{{color:var(--acc)}}.muted{{color:var(--muted)}}.small{{font-size:13px}}p{{margin:6px 0}}li{{margin:3px 0}}
.lead{{font-size:17px}}.k{{color:var(--ok)}}.w{{color:var(--warn)}}.b{{color:var(--bad)}}
.tw{{overflow-x:auto;margin:8px 0;border:1px solid var(--line);border-radius:10px}}table{{border-collapse:collapse;width:100%;font-size:14px}}
th,td{{padding:6px 9px;border-top:1px solid var(--line);text-align:left;vertical-align:top}}thead th{{border-top:0;background:#1b1a22;white-space:nowrap}}
td:first-child{{font-weight:600}}table.num td:not(:first-child){{white-space:nowrap}}
.card2{{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:12px 16px;margin:10px 0}}
.pair{{margin:14px 0 26px}}.ph{{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin:0 0 6px}}
.panes{{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(380px,100%),1fr));gap:12px}}.pane{{min-width:0}}.lab{{font-size:13px;color:var(--muted);margin:0 0 4px}}
.decision{{padding:18px 22px;background:#13131a;border:1px solid var(--line);border-radius:14px;margin:14px 0}}.decision h3{{margin-top:0}}
.opt{{display:block;border:1px solid var(--line);border-radius:10px;padding:9px 12px;margin:7px 0;cursor:pointer;background:#100e16}}.opt:has(input:checked){{background:oklch(26% 0.07 305);border-color:var(--acc)}}
.opt .t{{display:block;color:var(--muted);font-size:14px;margin-left:24px}}.rec{{font-size:12px;color:var(--ok);margin-left:6px}}
textarea{{width:100%;min-height:70px;background:#0c0b10;color:var(--fg);border:1px solid var(--line);border-radius:8px;padding:8px;font:inherit;margin-top:6px}}
.tabs{{display:flex;gap:6px;flex-wrap:wrap;margin:0 0 6px}}
.tab,button.replay{{font:inherit;font-size:13px;border-radius:999px;padding:4px 11px;border:1px solid var(--line);background:#1b1a22;color:var(--fg);cursor:pointer}}.tab.on{{background:oklch(26% 0.07 305);border-color:var(--acc)}}
button.replay{{background:oklch(26% 0.07 305);border-color:var(--acc)}}
form.decision button[type=submit]{{font:inherit;font-size:15px;font-weight:600;border-radius:999px;padding:8px 18px;border:1px solid var(--acc);background:oklch(26% 0.07 305);color:var(--fg);cursor:pointer;margin-top:8px}}
form.decision [role=status]{{font-size:13px;margin-left:10px;opacity:.8}}
.ctl{{position:sticky;top:0;z-index:8;background:#07070aee;backdrop-filter:blur(6px);border-bottom:1px solid var(--line);padding:8px 0;margin:0 0 8px;display:flex;gap:18px;flex-wrap:wrap;align-items:center;font-size:14px}}
.ctl label{{display:inline-flex;gap:6px;align-items:center;cursor:pointer}}
ol.plan>li{{margin:10px 0}}code{{font-size:13px;color:oklch(88% 0.08 305)}}
{CSS}
</style></head><body><main>
<h1>Wolfskin TV: faster, quieter, smoother</h1>
<p class="lead">Measured on your Xiaomi box with <b>Wolfskin (Opt)</b> 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.</p>
<div class="card2"><b>The short version</b><ul>
<li><b>The biggest wait isn't the app.</b> Two of four cold launches showed a spinner for <b>1.5 s</b>, and the other two showed none. The box looks up <code>jelly.justinvisser.org</code> through AdGuard Private DNS, and an uncached lookup takes <b>0.45–1.35 s</b>. 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.</li>
<li><b>The second biggest is Conductarr.</b> On a title page, the detail, season and "Ready for you" reads take <b>1.1 s, 1.1 s and 2.9 s</b> 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.</li>
<li><b>Screens are already quick, but they pop.</b> Home, the grids, Discover and Settings settle in <b>0.2–0.75 s</b>. 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.</li>
<li><b>Frames are slow for two reasons.</b> At the median a frame takes 30–46 ms during moves, where 60 fps needs 16.7 ms. On grid and row scrolls, <b>30–44 %</b> 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.</li>
</ul></div>
<div class="ctl"><label><input type="checkbox" id="boxrate"> Play prototypes at the box's frame rate (40 ms per frame, measured)</label><label><input type="checkbox" id="rm"> Reduced motion (fades only, ≤100 ms)</label><span class="muted small">Prototypes loop. "Replay together" restarts the recording and the prototype in step. Counters are ms after the screen first reacts to the key.</span></div>
<h2>1. Where the time goes today</h2>
<h3>Per screen, from the first frame that reacts to the key until the screen stops changing</h3>
<p class="muted small">Method: on-device <code>screenrecord</code> (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 <code>data/tv-spike-motion-speed/</code>.</p>
{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 <b class="b">+1.65 s</b> (DNS cold)', 'Spinner while <code>Users/Me</code> then <code>System/Info/Public</code> 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)', '<b class="w">2.69 s</b>', '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', '<b class="b">3.42 s</b>', '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')}
<h3>Frame timing per interaction (dumpsys gfxinfo, ART status <code>speed</code>)</h3>
{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')}
<p class="muted small">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 <b>14.3</b> / p90 <b>21.2</b> 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.</p>
<h3>Network, from tank (10 runs each; cold = new connection, warm = kept-alive)</h3>
{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', '<b class="w">96.8 KB</b>', '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)', '<b class="b">626 KB</b>', '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')}
<p class="muted small">Box-side DNS (ping timing, 6 samples 70 s apart): uncached <code>jelly.justinvisser.org</code> 490–1390 ms, <code>wolfskin-tv</code> 460–490 ms, <code>image.tmdb.org</code> 60–1170 ms; cached 40–50 ms. The box uses Private DNS <code>dns.adguard.com</code>. 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.</p>
<h3>Conductarr TV routes, server-side (Conductarr request log, 22 h, 200/304)</h3>
{table(['Route', 'n', 'p50', 'p95', 'Body p50', 'Why'], [
['activity/titles/{{tmdb}}/{{type}} (Ready for you)', '45', '<b class="b">2856 ms</b>', '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', '<b class="b">1198 ms</b>', '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', '<b class="w">204 KB, uncompressed</b>', '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')}
<p class="muted small">Every TV request also costs Conductarr an uncached Jellyfin <code>/Users/Me</code> to check the token (clients/jellyfin/token.go:42). Every authenticated response is <code>Cache-Control: no-store</code> (authority.go:32). The TV client revalidates with its own ETag store instead.</p>
<h3>Caches the app has today</h3>
{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'],
])}
<h2>2. Load states</h2>
<p>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.</p>
{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', '<b class="w">High on this GPU</b>: a moving gradient over every card, every frame', 'Light sweeps across the tiles. Not recommended'),
])}
<h3>See it: launch</h3>
{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'))}
<h3>See it: skeleton styles</h3>
<p class="muted small">Pick a style. Content lands at 900 ms in each, about the Movies grid's first visit.</p>
{pair('p-skel', 'skeleton', ('before-movies-grid', 'Today: Movies grid, first visit'), 'Options', 140, '', True)}
<h2>3. Motion and transitions</h2>
<p>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 <b>box frame rate</b> at the top to see that.</p>
{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', '<b class="w">High</b>. 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'),
])}
<h3>See it: Who's watching → Home</h3>
{pair('p-home', 'home', ('before-home-enter', 'Today: grey tiles, then art pops at 400 ms'), 'After', 200)}
<h3>See it: tab switch and row reveal (Home → Discover)</h3>
{pair('p-tab', 'tab', ('before-discover-tab', 'Today'), 'After', 150)}
<h3>See it: title page entry</h3>
{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'))}
<h3>See it: a late line on a series page</h3>
{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)}
<h3>See it: focus move</h3>
{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)}
<h3>See it: hero cross-fade</h3>
{pair('p-hero', 'hero', ('before-discover-tab', 'Today (Discover hero)'), 'Hero advances', 0, '', True)}
<h2>4. Caching</h2>
{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'),
])}
<h2>5. Backend retrieval</h2>
{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 <code>encode zstd gzip</code>)', '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 (<code>/Users/Me</code>) 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 (<code>fillWidth=256&fillHeight=384&quality=80&format=Webp</code>)', '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'),
])}
<h2>6. Kotlin and Compose</h2>
{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', '—', '—', '—', ''),
])}
<h2>7. Baseline profile</h2>
<p>Today: a hand seed of 6 lines (<code>app/src/optimized/baselineProfiles/baseline-prof.txt</code>), no generator and no Macrobenchmark. The box is at <code>speed</code> only because it was forced with <code>cmd package compile</code>. Every new install starts at <code>verify</code> (interpreted/JIT) until background dexopt runs.</p>
{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')}
<p><b>Expected from a recorded profile</b> (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 <b>not</b> change steady state on a box already at <code>speed</code>. Effort M: the journey can run on an emulator in the build container; the box itself isn't needed. Risk low.</p>
<h2>8. Recommended plan</h2>
<ol class="plan">
<li><b>DNS and launch (TV, S, ~1 day).</b> Persisted DNS answers plus parallel connection warm-up at process start. Show the picker from saved people and check the session in the background. <i>Prove it:</i> 6 cold launches spaced &gt;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.</li>
<li><b>Image sizes (TV, XS, hours).</b> Sized WebP grid posters, TMDB w342, lean episode fields. <i>Prove it:</i> first Movies visit settles ≤400 ms (today 750); tank payload table.</li>
<li><b>Load states and motion (TV, M, 2–3 days, against your pick on this page).</b> Breathing shaped skeletons, art fade-in for network loads only, reserved late-line slots, staged reveal, hero cross-fade, reduced motion. <i>Prove it:</i> recordings like the ones above, no layout change after first content, gfxinfo janky no worse than today.</li>
<li><b>Prefetch and memory cache (TV, M, 2 days).</b> Dwell prefetch of title pages, a memory layer over the Conductarr cache, pre-decoded Home art behind the picker. <i>Prove it:</i> title first content ≤100 ms and Home settled ≤250 ms.</li>
<li><b>Conductarr release (M–L, 2–4 days):</b> held-title cache with webhook invalidation, readiness without duplicate reads or per-episode GETs, 304 before render, token-check cache. <b>Caddy</b>: compress the TV door. <i>Prove it:</i> 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.</li>
<li><b>Baseline profile (TV build, M, 1–2 days).</b> Recorded journey profile. <i>Prove it:</i> fresh install without a forced compile, Home scroll janky ≤30 %, cold start ≤0.5 s.</li>
<li><b>Frame cost (TV, M, 2–3 days).</b> The Compose fixes in section 6 and fewer full-screen layers. <i>Prove it:</i> Home and grid scroll janky ≤20 %, GPU p90 ≤16 ms in framestats.</li>
</ol>
<p><b>Total:</b> 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.</p>
<p class="muted small">A box-side alternative to step 1's DNS part: switch the box's Private DNS from <code>dns.adguard.com</code> 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.</p>
<h2>9. Your picks (all of them on one page)</h2>
<p class="muted">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.</p>
<form class="decision" data-decision-key="tv-motion-speed-direction" data-lavish-question="tv-motion-speed-direction"><h3>1. Load-state and motion direction</h3>
<p class="muted">Decides plan step 3 (skeletons and motion). The prototypes above show each part.</p>
<label class="opt"><input required type="radio" name="tv-motion-speed-direction" value="recommended" data-choice-key="tv-motion-speed-direction"> <b>A · As recommended</b> <span class="rec">recommended</span><span class="t">Breathing shaped skeletons, art fades in only when it came from the network, reserved space for late lines, hero cross-fade, reduced-motion safe.</span></label>
<label class="opt"><input required type="radio" name="tv-motion-speed-direction" value="flat" data-choice-key="tv-motion-speed-direction"> <b>B · Same, but flat skeletons</b><span class="t">Still card-shaped, no pulse. The calmest option.</span></label>
<label class="opt"><input required type="radio" name="tv-motion-speed-direction" value="shimmer" data-choice-key="tv-motion-speed-direction"> <b>C · Same, but shimmer skeletons</b><span class="t">A light sweep across the tiles. Costs GPU on this box every frame while loading.</span></label>
<label class="opt"><input required type="radio" name="tv-motion-speed-direction" value="changes" data-choice-key="tv-motion-speed-direction"> <b>D · Different</b><span class="t">Say what in the note: a prototype to change, a speed, an option to drop.</span></label>
<textarea aria-label="Notes for 1. Load-state and motion direction" name="tv-motion-speed-direction-note" placeholder="Optional note"></textarea><p><button type="submit">Queue this answer</button> <span role="status" aria-live="polite"></span></p></form>
<form class="decision" data-decision-key="tv-speed-quick-fixes" data-lavish-question="tv-speed-quick-fixes"><h3>2. The two quick speed fixes (about a day and a half, TV only)</h3>
<p class="muted">(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.</p>
<label class="opt"><input required type="radio" name="tv-speed-quick-fixes" value="go" data-choice-key="tv-speed-quick-fixes"> <b>Go, start now</b> <span class="rec">recommended</span><span class="t">Two small lanes, merged and staged for the next install.</span></label>
<label class="opt"><input required type="radio" name="tv-speed-quick-fixes" value="a-only" data-choice-key="tv-speed-quick-fixes"> <b>Only (a), the launch spinner</b><span class="t">Leave the image sizing for later.</span></label>
<label class="opt"><input required type="radio" name="tv-speed-quick-fixes" value="b-only" data-choice-key="tv-speed-quick-fixes"> <b>Only (b), the image sizes</b><span class="t">Leave the DNS cache for later.</span></label>
<label class="opt"><input required type="radio" name="tv-speed-quick-fixes" value="later" data-choice-key="tv-speed-quick-fixes"> <b>Not now</b><span class="t">Skip until after the motion work.</span></label>
<textarea aria-label="Notes for 2. The two quick speed fixes (about a day and a half, TV only)" name="tv-speed-quick-fixes-note" placeholder="Optional note"></textarea><p><button type="submit">Queue this answer</button> <span role="status" aria-live="polite"></span></p></form>
<form class="decision" data-decision-key="tv-box-dns" data-lavish-question="tv-box-dns"><h3>3. Change the box Private DNS (your call, system setting)</h3>
<p class="muted">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.</p>
<label class="opt"><input required type="radio" name="tv-box-dns" value="leave" data-choice-key="tv-box-dns"> <b>Leave it as it is</b> <span class="rec">recommended</span><span class="t">The TV-side cache covers the launch spinner.</span></label>
<label class="opt"><input required type="radio" name="tv-box-dns" value="automatic" data-choice-key="tv-box-dns"> <b>Switch to Automatic</b><span class="t">I change it when you free the TV and show before and after timings.</span></label>
<label class="opt"><input required type="radio" name="tv-box-dns" value="ask" data-choice-key="tv-box-dns"> <b>Ask me when the TV is free</b><span class="t">Decide then.</span></label>
<textarea aria-label="Notes for 3. Change the box Private DNS (your call, system setting)" name="tv-box-dns-note" placeholder="Optional note"></textarea><p><button type="submit">Queue this answer</button> <span role="status" aria-live="polite"></span></p></form>
<form class="decision" data-decision-key="tv-speed-later-steps" data-lavish-question="tv-speed-later-steps"><h3>4. Later plan steps to queue (TV, after the build above)</h3>
<p class="muted">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.</p>
<label class="opt"><input required type="radio" name="tv-speed-later-steps" value="all" data-choice-key="tv-speed-later-steps"> <b>Queue all three in that order</b> <span class="rec">recommended</span><span class="t">They follow the motion work, one lane at a time.</span></label>
<label class="opt"><input required type="radio" name="tv-speed-later-steps" value="prefetch" data-choice-key="tv-speed-later-steps"> <b>Only prefetch and memory cache</b><span class="t">Skip the profile and frame work for now.</span></label>
<label class="opt"><input required type="radio" name="tv-speed-later-steps" value="none" data-choice-key="tv-speed-later-steps"> <b>None yet</b><span class="t">Revisit after you have seen the motion build on the TV.</span></label>
<textarea aria-label="Notes for 4. Later plan steps to queue (TV, after the build above)" name="tv-speed-later-steps-note" placeholder="Optional note"></textarea><p><button type="submit">Queue this answer</button> <span role="status" aria-live="polite"></span></p></form>
<form class="decision" data-decision-key="tv-conductarr-retrieval" data-lavish-question="tv-conductarr-retrieval"><h3>5. Conductarr title-read speed-up (needs a Conductarr release)</h3>
<p class="muted">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).</p>
<label class="opt"><input required type="radio" name="tv-conductarr-retrieval" value="scope" data-choice-key="tv-conductarr-retrieval"> <b>Scope it first, then I decide</b> <span class="rec">recommended</span><span class="t">A short scout writes the exact change list and risks for a release; nothing ships.</span></label>
<label class="opt"><input required type="radio" name="tv-conductarr-retrieval" value="go" data-choice-key="tv-conductarr-retrieval"> <b>Go: build it and release it</b><span class="t">You are giving the word to build and release Conductarr when it is green.</span></label>
<label class="opt"><input required type="radio" name="tv-conductarr-retrieval" value="later" data-choice-key="tv-conductarr-retrieval"> <b>Not now</b><span class="t">Skip until after the TV-side wins.</span></label>
<textarea aria-label="Notes for 5. Conductarr title-read speed-up (needs a Conductarr release)" name="tv-conductarr-retrieval-note" placeholder="Optional note"></textarea><p><button type="submit">Queue this answer</button> <span role="status" aria-live="polite"></span></p></form>
<form class="decision" data-decision-key="tv-build-order" data-lavish-question="tv-build-order"><h3>6. When should these land on the TV?</h3>
<p class="muted">You said you will not be using the TV during this session.</p>
<label class="opt"><input required type="radio" name="tv-build-order" value="as-ready" data-choice-key="tv-build-order"> <b>Bundle with the next install</b> <span class="rec">recommended</span><span class="t">The speed fixes and any approved motion work join the staged build; I install when you say the TV is free.</span></label>
<label class="opt"><input required type="radio" name="tv-build-order" value="separate" data-choice-key="tv-build-order"> <b>Install the speed fixes on their own first</b><span class="t">A quick build for steps 2(a) and 2(b) only, installed as soon as it is ready if you free the TV.</span></label>
<textarea aria-label="Notes for 6. When should these land on the TV?" name="tv-build-order-note" placeholder="Optional note"></textarea><p><button type="submit">Queue this answer</button> <span role="status" aria-live="polite"></span></p></form>
<p class="muted small">Method, raw numbers and files: <code>data/tv-spike-motion-speed/report.md</code>. Nothing was changed in the app or on the server. The box was left on the Movies tab.</p>
<script>
{JS}
document.querySelectorAll('form.decision').forEach(form=>{{form.addEventListener('submit',event=>{{event.preventDefault();const key=form.dataset.lavishQuestion;const answer=new FormData(form).get(key);const status=form.querySelector('[role=status]');if(!answer){{status.textContent='Pick an option first.';return;}}if(!window.lavish||!window.lavish.queuePrompt){{status.textContent='Open this page in the live review board to queue an answer.';return;}}const note=form.querySelector('textarea').value.trim();const label=form.querySelector('h3').textContent;window.lavish.queuePrompt(label+': '+answer+(note?'\\n'+note:''),{{tag:'choice',text:label+': '+answer,element:form,queueKey:key,data:{{question:key,answer:answer,note:note}}}});status.textContent='Queued. Press Send to Agent in the review toolbar.';}});}});
</script></main></body></html>'''
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))

View file

@ -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"
}

View file

@ -1,483 +0,0 @@
<!doctype html><html lang="en" data-lavish-annotate="off"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="dark"><title>Wolfskin TV: faster, quieter, smoother</title><style>
:root{color-scheme:dark;--bg:#07070a;--panel:#13131a;--line:rgba(255,255,255,.1);--fg:#f4f4f6;--muted:rgba(244,244,246,.74);--acc:oklch(78% 0.14 305);--ok:#7ed6a0;--warn:#ffc46b;--bad:#ff8a7a}
*{box-sizing:border-box}html{-webkit-text-size-adjust:100%}body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.5 system-ui,-apple-system,Segoe UI,sans-serif}
main{max-width:1400px;margin:0 auto;padding:16px 14px 120px}h1{font-size:28px;line-height:1.2;margin:6px 0 6px}h2{font-size:21px;margin:34px 0 8px;color:var(--acc);border-bottom:1px solid var(--line);padding-bottom:4px}
h3{font-size:17px;margin:20px 0 6px}a{color:var(--acc)}.muted{color:var(--muted)}.small{font-size:13px}p{margin:6px 0}li{margin:3px 0}
.lead{font-size:17px}.k{color:var(--ok)}.w{color:var(--warn)}.b{color:var(--bad)}
.tw{overflow-x:auto;margin:8px 0;border:1px solid var(--line);border-radius:10px}table{border-collapse:collapse;width:100%;font-size:14px}
th,td{padding:6px 9px;border-top:1px solid var(--line);text-align:left;vertical-align:top}thead th{border-top:0;background:#1b1a22;white-space:nowrap}
td:first-child{font-weight:600}table.num td:not(:first-child){white-space:nowrap}
.card2{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:12px 16px;margin:10px 0}
.pair{margin:14px 0 26px}.ph{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin:0 0 6px}
.panes{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(380px,100%),1fr));gap:12px}.pane{min-width:0}.lab{font-size:13px;color:var(--muted);margin:0 0 4px}
.decision{padding:18px 22px;background:#13131a;border:1px solid var(--line);border-radius:14px;margin:14px 0}.decision h3{margin-top:0}
.opt{display:block;border:1px solid var(--line);border-radius:10px;padding:9px 12px;margin:7px 0;cursor:pointer;background:#100e16}.opt:has(input:checked){background:oklch(26% 0.07 305);border-color:var(--acc)}
.opt .t{display:block;color:var(--muted);font-size:14px;margin-left:24px}.rec{font-size:12px;color:var(--ok);margin-left:6px}
textarea{width:100%;min-height:70px;background:#0c0b10;color:var(--fg);border:1px solid var(--line);border-radius:8px;padding:8px;font:inherit;margin-top:6px}
.tabs{display:flex;gap:6px;flex-wrap:wrap;margin:0 0 6px}
.tab,button.replay{font:inherit;font-size:13px;border-radius:999px;padding:4px 11px;border:1px solid var(--line);background:#1b1a22;color:var(--fg);cursor:pointer}.tab.on{background:oklch(26% 0.07 305);border-color:var(--acc)}
button.replay{background:oklch(26% 0.07 305);border-color:var(--acc)}
form.decision button[type=submit]{font:inherit;font-size:15px;font-weight:600;border-radius:999px;padding:8px 18px;border:1px solid var(--acc);background:oklch(26% 0.07 305);color:var(--fg);cursor:pointer;margin-top:8px}
form.decision [role=status]{font-size:13px;margin-left:10px;opacity:.8}
.ctl{position:sticky;top:0;z-index:8;background:#07070aee;backdrop-filter:blur(6px);border-bottom:1px solid var(--line);padding:8px 0;margin:0 0 8px;display:flex;gap:18px;flex-wrap:wrap;align-items:center;font-size:14px}
.ctl label{display:inline-flex;gap:6px;align-items:center;cursor:pointer}
ol.plan>li{margin:10px 0}code{font-size:13px;color:oklch(88% 0.08 305)}
/* 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}
</style></head><body><main>
<h1>Wolfskin TV: faster, quieter, smoother</h1>
<p class="lead">Measured on your Xiaomi box with <b>Wolfskin (Opt)</b> 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.</p>
<div class="card2"><b>The short version</b><ul>
<li><b>The biggest wait isn't the app.</b> Two of four cold launches showed a spinner for <b>1.5 s</b>, and the other two showed none. The box looks up <code>jelly.justinvisser.org</code> through AdGuard Private DNS, and an uncached lookup takes <b>0.45–1.35 s</b>. 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.</li>
<li><b>The second biggest is Conductarr.</b> On a title page, the detail, season and "Ready for you" reads take <b>1.1 s, 1.1 s and 2.9 s</b> 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.</li>
<li><b>Screens are already quick, but they pop.</b> Home, the grids, Discover and Settings settle in <b>0.2–0.75 s</b>. 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.</li>
<li><b>Frames are slow for two reasons.</b> At the median a frame takes 30–46 ms during moves, where 60 fps needs 16.7 ms. On grid and row scrolls, <b>30–44 %</b> 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.</li>
</ul></div>
<div class="ctl"><label><input type="checkbox" id="boxrate"> Play prototypes at the box's frame rate (40 ms per frame, measured)</label><label><input type="checkbox" id="rm"> Reduced motion (fades only, ≤100 ms)</label><span class="muted small">Prototypes loop. "Replay together" restarts the recording and the prototype in step. Counters are ms after the screen first reacts to the key.</span></div>
<h2>1. Where the time goes today</h2>
<h3>Per screen, from the first frame that reacts to the key until the screen stops changing</h3>
<p class="muted small">Method: on-device <code>screenrecord</code> (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 <code>data/tv-spike-motion-speed/</code>.</p>
<div class="tw"><table class="num"><thead><tr><th>Screen</th><th>First content</th><th>Settled</th><th>What arrives late</th><th>Repeat visit</th></tr></thead><tbody><tr><td>Cold start (am start -W TotalTime, 4 runs)</td><td>583 / 538 / 438 / 453 ms to first frame</td><td>picker at +0.3–0.4 s (DNS warm) or <b class="b">+1.65 s</b> (DNS cold)</td><td>Spinner while <code>Users/Me</code> then <code>System/Info/Public</code> run one after the other (ServerRepository.kt:104,107)</td><td>same, bimodal on the 60 s DNS cache</td></tr><tr><td>Who's watching → Home</td><td>hero text 135 ms</td><td>430 ms</td><td>shelf tiles flat grey until 400 ms, then all images pop</td><td>370 ms</td></tr><tr><td>Home → Discover tab</td><td>content 270 ms (dims first)</td><td>700 ms</td><td>posters pop in over 0.4 s</td><td>600 ms</td></tr><tr><td>Movies grid</td><td>230 ms (blank tiles)</td><td>750 ms</td><td>posters fill row by row</td><td>500 ms</td></tr><tr><td>Shows grid</td><td>280 ms</td><td>640 ms</td><td>posters</td><td>—</td></tr><tr><td>Title, movie in library (American Gangster)</td><td>430 ms (text)</td><td><b class="w">2.69 s</b></td><td>backdrop 470 ms; cast portraits 2.7 s (initials until then)</td><td>450 ms total</td></tr><tr><td>Title, Discover pick (Donnie Brasco)</td><td>980 ms (blank skeleton until then)</td><td>1.53 s</td><td>backdrop 1.37 s, cast 1.5 s</td><td>—</td></tr><tr><td>Title, series (Adults)</td><td>620 ms</td><td><b class="b">3.42 s</b></td><td>ratings and "Ready for you · by Mindel" land at 3.4 s and push the overview down</td><td>—</td></tr><tr><td>Person page (Martin Sheen)</td><td>620 ms</td><td>720 ms</td><td>filmography posters</td><td>—</td></tr><tr><td>Back from title / series / person</td><td>260 / 300 / 450 ms</td><td></td><td></td><td></td></tr><tr><td>Settings</td><td>220 ms</td><td>220 ms</td><td>—</td><td></td></tr><tr><td>Coming in sheet</td><td>74 ms</td><td>74 ms (from cache)</td><td>posters refresh silently</td><td></td></tr></tbody></table></div>
<h3>Frame timing per interaction (dumpsys gfxinfo, ART status <code>speed</code>)</h3>
<div class="tw"><table class="num"><thead><tr><th>Interaction</th><th>Frames</th><th>Janky</th><th>p50</th><th>p90</th><th>p99</th><th>Main thread slow / GPU slow</th></tr></thead><tbody><tr><td>Home rows scroll (down 7, up 6, right 5), run 1 / 2</td><td>149 / 115</td><td>38.9 % / 34.8 %</td><td>44 / 46 ms</td><td>69 / 65</td><td>89 / 101</td><td>43 / 52 · 27 / 35</td></tr><tr><td>Movies grid scroll (down 8, up 8), run 1 / 2</td><td>180 / 159</td><td>43.9 % / 32.1 %</td><td>30 / 30 ms</td><td>53 / 46</td><td>93 / 73</td><td>30 / 69 · 20 / 48</td></tr><tr><td>Discover scroll</td><td>146</td><td>30.1 %</td><td>34 ms</td><td>57</td><td>73</td><td>34 / 35</td></tr><tr><td>Tab switch ×4</td><td>158</td><td>18.4 %</td><td>28 ms</td><td>65</td><td>150</td><td>15 / 29</td></tr><tr><td>Title open + back ×3</td><td>103</td><td>33.0 %</td><td>48 ms</td><td>113</td><td>150</td><td>29 / 34</td></tr><tr><td>Who's watching → Home</td><td>15</td><td>53.3 %</td><td>61 ms</td><td>117</td><td>133</td><td>7 / 8</td></tr><tr><td>Open title (movie, first time)</td><td>20</td><td>60.0 %</td><td>57 ms</td><td>150</td><td>200</td><td>9 / 12</td></tr><tr><td>Back from person</td><td>12</td><td>75.0 %</td><td>93 ms</td><td>150</td><td>150</td><td>8 / 9</td></tr></tbody></table></div>
<p class="muted small">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 <b>14.3</b> / p90 <b>21.2</b> 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.</p>
<h3>Network, from tank (10 runs each; cold = new connection, warm = kept-alive)</h3>
<div class="tw"><table class="num"><thead><tr><th>Call</th><th>Bytes</th><th>Cold p50 / p95</th><th>Warm p50 / p95</th><th>Cache headers</th></tr></thead><tbody><tr><td>Jellyfin UserViews (called 4–5× per launch)</td><td>2.3 KB</td><td>65 / 166 ms</td><td>46 / 51 ms</td><td>none</td></tr><tr><td>Home Resume (20)</td><td>1.4 KB</td><td>58 / 66</td><td>30 / 41</td><td>none</td></tr><tr><td>Home NextUp (20)</td><td>6.2 KB</td><td>71 / 91</td><td>47 / 52</td><td>none</td></tr><tr><td>Home Items/{{id}} per series (sequential)</td><td>4.0 KB</td><td>43 / 60</td><td>24 / 28</td><td>none</td></tr><tr><td>Home library shelf (20)</td><td>18.7 KB</td><td>85 / 116</td><td>74 / 84</td><td>none</td></tr><tr><td>Movies grid page (200)</td><td>51 KB</td><td>68 / 148</td><td>61 / 64</td><td>none</td></tr><tr><td>Title episodes with MediaSources + People</td><td><b class="w">96.8 KB</b></td><td>142 / 185</td><td>122 / 169</td><td>none</td></tr><tr><td>Title episodes, lean fields</td><td>6.5 KB</td><td>26 / 65</td><td>11 / 12</td><td>none</td></tr><tr><td>Grid poster, no size (today, LibraryReadFacade.kt:86)</td><td><b class="b">626 KB</b></td><td>26 / 30</td><td>8 / 9</td><td>max-age 1 y, immutable, ETag</td></tr><tr><td>Grid poster fillWidth 256×384 q96</td><td>31.6 KB</td><td>19 / 131</td><td>2 / 4</td><td>same</td></tr><tr><td>Grid poster 256×384 q80 WebP</td><td>11.2 KB</td><td>19 / 118</td><td>1 / 2</td><td>same</td></tr><tr><td>Backdrop 1280 q96 / q80 WebP</td><td>91 / 31 KB</td><td>19 / 25</td><td>2 / 2</td><td>same</td></tr><tr><td>TMDB poster w780 (today, ConductarrProjection.kt:9)</td><td>131 KB</td><td>128 / 144</td><td>49 / 70</td><td>max-age 1 y</td></tr><tr><td>TMDB poster w342 / w185</td><td>41 / 15 KB</td><td>100 / 76</td><td>31 / 30</td><td>max-age 1 y</td></tr></tbody></table></div>
<p class="muted small">Box-side DNS (ping timing, 6 samples 70 s apart): uncached <code>jelly.justinvisser.org</code> 490–1390 ms, <code>wolfskin-tv</code> 460–490 ms, <code>image.tmdb.org</code> 60–1170 ms; cached 40–50 ms. The box uses Private DNS <code>dns.adguard.com</code>. 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.</p>
<h3>Conductarr TV routes, server-side (Conductarr request log, 22 h, 200/304)</h3>
<div class="tw"><table class="num"><thead><tr><th>Route</th><th>n</th><th>p50</th><th>p95</th><th>Body p50</th><th>Why</th></tr></thead><tbody><tr><td>activity/titles/{{tmdb}}/{{type}} (Ready for you)</td><td>45</td><td><b class="b">2856 ms</b></td><td>5002 ms</td><td>1.7 KB</td><td>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</td></tr><tr><td>catalogue/titles/{{tmdb}}/{{type}} (detail)</td><td>51</td><td><b class="b">1198 ms</b></td><td>3217 ms</td><td>11.9 KB</td><td>Held-title read lists every Arr library unpaged, then Seerr details, then ratings; sequential (catalogue/detail.go:14-17, library_detail.go:81)</td></tr><tr><td>catalogue/tv/{{tmdb}}/seasons/{{n}}</td><td>76 / 17 (304)</td><td>1084 / 2282 ms</td><td>2664 ms</td><td>1 KB</td><td>Same held-title read; a 304 still renders and hashes the full body first (read_validators.go)</td></tr><tr><td>…/ratings</td><td>265</td><td>147 ms</td><td>449 ms</td><td>0.5 KB</td><td>6 h server cache, 300 titles</td></tr><tr><td>…/enrichment · …/related · …/cast</td><td>46 · 42 · 26</td><td>103 · 124 · 33 ms</td><td>465 · 261 · 78</td><td>3 · 14 · 12.5 KB</td><td>enrichment uncached</td></tr><tr><td>recommendations feed</td><td>42</td><td>16 ms</td><td>40 ms</td><td><b class="w">204 KB, uncompressed</b></td><td>no gzip/br on the TV door (Caddyfile.base:316-333, serve.go:140)</td></tr><tr><td>activity (polled every 30 s)</td><td>713 × 304</td><td>52 ms</td><td>83 ms</td><td>62.5 KB when 200</td><td></td></tr><tr><td>operator · profiles · dismissals · options · display</td><td></td><td>12–25 ms</td><td>30–93 ms</td><td></td><td>cheap; display is called ≈1 400×/day, once per poster</td></tr></tbody></table></div>
<p class="muted small">Every TV request also costs Conductarr an uncached Jellyfin <code>/Users/Me</code> to check the token (clients/jellyfin/token.go:42). Every authenticated response is <code>Cache-Control: no-store</code> (authority.go:32). The TV client revalidates with its own ETag store instead.</p>
<h3>Caches the app has today</h3>
<div class="tw"><table class=""><thead><tr><th>Cache</th><th>Where</th><th>Behaviour</th><th>Observed</th></tr></thead><tbody><tr><td>Wolfskin image loader (Coil 3)</td><td>WolfskinImages.kt:46-61</td><td>64 MiB memory, 128 MiB disk, 4 parallel reads, crossfade off</td><td>repeat visits paint art from memory; first grid visit downloads 626 KB per poster (no size)</td></tr><tr><td>Upstream Wholphin image loader</td><td>CoilConfig.kt:80-101</td><td>separate memory + 200 MB disk</td><td>a second copy of the same art in memory</td></tr><tr><td>Conductarr JSON</td><td>FileCacheStore, noBackupFilesDir/conductarr</td><td>saved copy shown first, then revalidated with ETag; no memory layer</td><td>title re-open: every ON_OPEN route re-fetched (cast 304, detail 875 ms again)</td></tr><tr><td>Home, library, title, person snapshots</td><td>filesDir/wolfskin-home, -library-cache, -title</td><td>disk JSON, painted first</td><td>repeat visits 370–500 ms instead of 430–750</td></tr><tr><td>HTTP cache (OkHttp)</td><td>none</td><td>—</td><td>Jellyfin JSON never cached; images rely on Coil only</td></tr><tr><td>DNS</td><td>system only</td><td>60 s TTL through Private DNS</td><td>1.5 s launch spinner when expired</td></tr></tbody></table></div>
<h2>2. Load states</h2>
<p>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.</p>
<div class="tw"><table class=""><thead><tr><th>Option</th><th>Expected gain (measured basis)</th><th>Effort</th><th>Risk</th><th>What you would see</th><th>Ships in</th></tr></thead><tbody><tr><td>Picker from the saved people list, verify in the background</td><td>Removes the 1.5 s spinner on every DNS-cold launch (2 of 4 measured)</td><td>S</td><td>Low. A revoked session must still land on the recovery picker</td><td>The launcher fades straight into Who's watching</td><td>TV</td></tr><tr><td>Shaped skeletons that breathe (opacity only, staggered)</td><td>No speed change; replaces the flat grey and spinner states</td><td>S</td><td>Low. One alpha layer per row</td><td>Card-shaped tiles gently pulsing until the art lands</td><td>TV</td></tr><tr><td>Art fades in (120–160 ms) instead of popping</td><td>Removes the pop on every first visit</td><td>XS</td><td>Low. Keep it off for memory-cache hits so repeat visits stay instant</td><td>Posters settle instead of flickering in</td><td>TV</td></tr><tr><td>Reserve space for late fields (ratings, Ready for you, genres, overview)</td><td>No layout jump at 3.4 s on series pages</td><td>S</td><td>Low</td><td>The line fades into a gap that was already there</td><td>TV</td></tr><tr><td>Title page paints what the card already knew</td><td>First content 430–980 ms → about 60 ms</td><td>M</td><td>Medium. Card data must match detail (year, runtime, ratings)</td><td>Title, year, ratings and Play at once, over a colour wash of the poster</td><td>TV</td></tr><tr><td>Shimmer sweep skeletons</td><td>None</td><td>S</td><td><b class="w">High on this GPU</b>: a moving gradient over every card, every frame</td><td>Light sweeps across the tiles. Not recommended</td><td>TV</td></tr></tbody></table></div>
<h3>See it: launch</h3>
<div class="pair"><div class="ph"><button type="button" class="replay">Replay together</button></div>
<div class="panes"><div class="pane"><p class="lab">Today, DNS cold (2 of 4 launches): 1.5 s spinner</p><div class="sbox"><video src="tv-motion-speed/before-launch-spinner.webm" muted loop playsinline autoplay preload="auto"></video></div></div><div class="pane"><p class="lab">Today, DNS warm</p><div class="sbox"><video src="tv-motion-speed/before-launch-fast.webm" muted loop playsinline autoplay preload="auto"></video></div></div><div class="pane"><p class="lab">After: no spinner, people from the saved list</p><div class="sbox" id="p-launch" data-scene="launch" data-lead="180"></div></div></div></div>
<h3>See it: skeleton styles</h3>
<p class="muted small">Pick a style. Content lands at 900 ms in each, about the Movies grid's first visit.</p>
<div class="pair"><div class="ph"><button type="button" class="replay">Replay together</button></div>
<div class="panes"><div class="pane"><p class="lab">Today: Movies grid, first visit</p><div class="sbox"><video src="tv-motion-speed/before-movies-grid.webm" muted loop playsinline autoplay preload="auto"></video></div></div><div class="pane"><p class="lab">Options</p><div class="tabs" data-variants="p-skel"></div><div class="sbox" id="p-skel" data-scene="skeleton" data-lead="140"></div></div></div></div>
<h2>3. Motion and transitions</h2>
<p>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 <b>box frame rate</b> at the top to see that.</p>
<div class="tw"><table class=""><thead><tr><th>Option</th><th>Expected gain (measured basis)</th><th>Effort</th><th>Risk</th><th>What you would see</th><th>Ships in</th></tr></thead><tbody><tr><td>Staged reveal on screen entry (hero copy rises 16 px, rows rise 24 px, staggered 30–40 ms, 180 ms ease-out)</td><td>Perceived: art arrives into motion instead of popping. Cost: translate + alpha on one layer per row</td><td>S</td><td>Low. Stagger must not delay focus</td><td>Home and Discover settle like a curtain rather than tiles snapping</td><td>TV</td></tr><tr><td>Hero cross-fade (backdrop 320 ms, copy hands off)</td><td>Removes the hard cut when the hero changes</td><td>S</td><td>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</td><td>The next pick dissolves in</td><td>TV</td></tr><tr><td>Focus: 180 ms ease-out, caption rises 10 px</td><td>Same frame cost as today; the curve hides dropped frames better</td><td>XS</td><td>Low</td><td>Focus glides rather than ticks</td><td>TV</td></tr><tr><td>Title entry: grid fades 160 ms, page builds from the poster colour</td><td>Hides the 470 ms backdrop fetch</td><td>M</td><td>Medium (see load states)</td><td>No black gap between grid and title</td><td>TV</td></tr><tr><td>Shared-element poster → title sheet</td><td>None measurable</td><td>L</td><td><b class="w">High</b>. Compose shared elements across NavHost destinations on this GPU; the movie page has no poster sheet when a backdrop exists</td><td>The card flies into place. Not recommended now</td><td>TV</td></tr><tr><td>Honour reduced motion (system animator scale 0 or a Wolfskin toggle)</td><td>Accessibility</td><td>S</td><td>Low</td><td>Fades only, ≤100 ms; the toggle at the top shows it</td><td>TV</td></tr></tbody></table></div>
<h3>See it: Who's watching → Home</h3>
<div class="pair"><div class="ph"><button type="button" class="replay">Replay together</button></div>
<div class="panes"><div class="pane"><p class="lab">Today: grey tiles, then art pops at 400 ms</p><div class="sbox"><video src="tv-motion-speed/before-home-enter.webm" muted loop playsinline autoplay preload="auto"></video></div></div><div class="pane"><p class="lab">After</p><div class="sbox" id="p-home" data-scene="home" data-lead="200"></div></div></div></div>
<h3>See it: tab switch and row reveal (Home → Discover)</h3>
<div class="pair"><div class="ph"><button type="button" class="replay">Replay together</button></div>
<div class="panes"><div class="pane"><p class="lab">Today</p><div class="sbox"><video src="tv-motion-speed/before-discover-tab.webm" muted loop playsinline autoplay preload="auto"></video></div></div><div class="pane"><p class="lab">After</p><div class="sbox" id="p-tab" data-scene="tab" data-lead="150"></div></div></div></div>
<h3>See it: title page entry</h3>
<div class="pair"><div class="ph"><button type="button" class="replay">Replay together</button></div>
<div class="panes"><div class="pane"><p class="lab">Today: American Gangster, first open (cast at 2.7 s)</p><div class="sbox"><video src="tv-motion-speed/before-title-movie.webm" muted loop playsinline autoplay preload="auto"></video></div></div><div class="pane"><p class="lab">Today: Donnie Brasco from Discover, blank until 980 ms</p><div class="sbox"><video src="tv-motion-speed/before-title-discover.webm" muted loop playsinline autoplay preload="auto"></video></div></div><div class="pane"><p class="lab">After: Apocalypse Now from the same grid</p><div class="sbox" id="p-title" data-scene="title" data-lead="170"></div></div></div></div>
<h3>See it: a late line on a series page</h3>
<div class="pair"><div class="ph"><button type="button" class="replay">Replay together</button></div>
<div class="panes"><div class="pane"><p class="lab">Today: Adults, the readiness line lands at 3.4 s and pushes the text down</p><div class="sbox"><video src="tv-motion-speed/before-title-series.webm" muted loop playsinline autoplay preload="auto"></video></div></div><div class="pane"><p class="lab">After</p><div class="tabs" data-variants="p-series"></div><div class="sbox" id="p-series" data-scene="series" data-lead="150"></div></div></div></div>
<h3>See it: focus move</h3>
<div class="pair"><div class="ph"><button type="button" class="replay">Replay together</button><span class="muted small">Tick box frame rate to compare at 25 fps.</span></div>
<div class="panes"><div class="pane"><p class="lab">Today, real grid</p><div class="sbox"><video src="tv-motion-speed/before-movies-grid.webm" muted loop playsinline autoplay preload="auto"></video></div></div><div class="pane"><p class="lab">Focus along a row</p><div class="tabs" data-variants="p-focus"></div><div class="sbox" id="p-focus" data-scene="focus" data-lead="0"></div></div></div></div>
<h3>See it: hero cross-fade</h3>
<div class="pair"><div class="ph"><button type="button" class="replay">Replay together</button></div>
<div class="panes"><div class="pane"><p class="lab">Today (Discover hero)</p><div class="sbox"><video src="tv-motion-speed/before-discover-tab.webm" muted loop playsinline autoplay preload="auto"></video></div></div><div class="pane"><p class="lab">Hero advances</p><div class="tabs" data-variants="p-hero"></div><div class="sbox" id="p-hero" data-scene="hero" data-lead="0"></div></div></div></div>
<h2>4. Caching</h2>
<div class="tw"><table class=""><thead><tr><th>Option</th><th>Expected gain (measured basis)</th><th>Effort</th><th>Risk</th><th>What you would see</th><th>Ships in</th></tr></thead><tbody><tr><td>Persisted DNS answers (OkHttp Dns: serve the last address at once, refresh behind)</td><td>Removes 0.45–1.35 s on launch and on the first TMDB image after any 60 s pause</td><td>S</td><td>Low. Fall back to the system lookup if the cached address fails</td><td>No spinner on launch; first cast photos in ≈0.6 s instead of 2.7 s [inference: DNS is the cast delay]</td><td>TV</td></tr><tr><td>Warm the connections at process start (Jellyfin, Conductarr door, TMDB) in parallel</td><td>Overlaps DNS + TLS with the 450 ms first frame</td><td>XS</td><td>Low</td><td>Same as above, smaller</td><td>TV</td></tr><tr><td>Memory layer over the Conductarr disk cache</td><td>Skips disk read and JSON decode on back and re-open (title re-open settles at 450 ms today; ~250 expected) [inference]</td><td>S</td><td>Low</td><td>Back and re-open feel instant</td><td>TV</td></tr><tr><td>Stale-while-revalidate for ON_OPEN title routes (skip refetch if validated in the last 5 min)</td><td>Re-opening a title stops costing the server 875–2 900 ms</td><td>S</td><td>Low. Requests and readiness may be 5 min old; keep requests on FORCE</td><td>Nothing visible; less churn</td><td>TV</td></tr><tr><td>Prefetch the focused card's title page (detail JSON + backdrop) after 350 ms of dwell</td><td>Title first content 430 ms → about 60 ms; backdrop already decoded</td><td>M</td><td>Medium. Bandwidth: at most one title per dwell; cancel on move</td><td>Opening a title is instant</td><td>TV</td></tr><tr><td>Pre-decode last-known Home art while Who's watching is up</td><td>Home settles at 430 ms → about 250 ms with no pops</td><td>S</td><td>Low. Memory: 6 backdrops ≈ 21 MiB of the 64 MiB</td><td>Home appears whole</td><td>TV</td></tr><tr><td>One image loader instead of two</td><td>Frees a second memory cache (Coil default ≈ 25 % of the heap) [inference]</td><td>M</td><td>Medium. Upstream screens use their own</td><td>Fewer re-downloads after big screens</td><td>TV</td></tr><tr><td>OkHttp HTTP cache for Jellyfin JSON</td><td>Small. Jellyfin sends no cache headers, so only conditional revalidation is possible</td><td>S</td><td>Low</td><td>Nothing visible</td><td>TV</td></tr></tbody></table></div>
<h2>5. Backend retrieval</h2>
<div class="tw"><table class=""><thead><tr><th>Option</th><th>Expected gain (measured basis)</th><th>Effort</th><th>Risk</th><th>What you would see</th><th>Ships in</th></tr></thead><tbody><tr><td>Cache the held-title read in Conductarr (Arr library list, queue, history) for 30–60 s, shared across routes</td><td>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]</td><td>M</td><td>Medium. Freshness of "Ready" after an import; invalidate on Arr webhook (already received)</td><td>Series pages settle in under a second; no late line</td><td>Conductarr release</td></tr><tr><td>Readiness: drop the duplicate held-title read and the per-episode Jellyfin GETs</td><td>The 5 s tail on readiness</td><td>M</td><td>Medium</td><td>"Ready for you" appears with the page</td><td>Conductarr release</td></tr><tr><td>Compress the TV door (Caddy <code>encode zstd gzip</code>)</td><td>Feed 204 KB → ~25–35 KB [inference: JSON typically 6–8×], activity 62 KB → ~8 KB</td><td>XS</td><td>Low</td><td>Discover opens a little faster on Wi-Fi</td><td>Caddy change (server-docs)</td></tr><tr><td>304 before rendering (keep a per-route version, not a hash of the full body)</td><td>Season 304 2.3 s → ms</td><td>M</td><td>Medium</td><td>Nothing visible except speed</td><td>Conductarr release</td></tr><tr><td>Cache the token check (<code>/Users/Me</code>) for 60 s per token</td><td>Removes a Jellyfin round trip from every TV request (~10–40 ms each, 6–10 per title page)</td><td>S</td><td>Low. Revocation lag 60 s</td><td>Nothing visible</td><td>Conductarr release</td></tr><tr><td>Batch display reads (one call for a row of posters)</td><td>≈1 400 display calls/day → a few dozen</td><td>M</td><td>Low</td><td>Coming in and Discover rows fill together</td><td>Conductarr release + TV</td></tr><tr><td>Size the grid posters (<code>fillWidth=256&fillHeight=384&quality=80&format=Webp</code>)</td><td>626 KB → 11 KB per poster (57×); 85 films 53 MB → 1 MB</td><td>XS</td><td>Low</td><td>Movies and Shows fill at once on first visit</td><td>TV</td></tr><tr><td>Ask TMDB for w342 posters, not w780</td><td>131 KB → 41 KB per Discover poster</td><td>XS</td><td>Low. 342 px for a 256 px card</td><td>Discover rows fill sooner</td><td>TV</td></tr><tr><td>Lean episode fields (drop MediaSources/MediaStreams/People from the list; fetch them on Play)</td><td>96.8 KB → 6.5 KB per season, 142 → 26 ms</td><td>S</td><td>Medium. The episode row shows file chips</td><td>Series pages settle sooner</td><td>TV</td></tr><tr><td>Fewer launch calls: UserViews 4–5× and LiveTv/Recordings/Folders 2× per launch</td><td>≈250 ms of serial calls</td><td>S</td><td>Low</td><td>Nothing visible</td><td>TV</td></tr></tbody></table></div>
<h2>6. Kotlin and Compose</h2>
<div class="tw"><table class=""><thead><tr><th>Option</th><th>Expected gain (measured basis)</th><th>Effort</th><th>Risk</th><th>What you would see</th><th>Ships in</th></tr></thead><tbody><tr><td>Library load re-emits the full list per 200-item page and re-sorts each time (LibraryReadFacade.kt:96, LibraryGridState.kt:99)</td><td>Grid first visit; matters more as the library grows</td><td>S</td><td>Low</td><td>Grid appears in one step</td><td>TV</td></tr><tr><td>Hoist the Regex in titleFallbackBackground / titleAmbientBackground (TitleArtwork.kt:152,167)</td><td>Draw-time cost per card</td><td>XS</td><td>Low</td><td>—</td><td>TV</td></tr><tr><td>Discover reads firstVisibleItemIndex in composition (DiscoverScreen.kt:107) → derivedStateOf</td><td>Fewer recompositions while scrolling Discover (34 of 44 janky frames were UI-thread)</td><td>XS</td><td>Low</td><td>Smoother Discover scroll</td><td>TV</td></tr><tr><td>NavBar label colour read in composition (NavBar.kt:86) → draw-phase</td><td>Tab switch frames</td><td>XS</td><td>Low</td><td>—</td><td>TV</td></tr><tr><td>WolfskinImages.loader() is @Synchronized and called in composition</td><td>Lock on every poster recomposition</td><td>XS</td><td>Low</td><td>—</td><td>TV</td></tr><tr><td>Cut full-screen layers: hero blur (RenderEffect) + two full-screen gradient scrims + ambient</td><td>GPU 14–21 ms/frame is the floor today; pre-blurred tiny art and one combined scrim drawn once</td><td>M</td><td>Medium. Must match the approved frames</td><td>Smoother scroll on Home and Discover</td><td>TV</td></tr><tr><td>Keys and contentType</td><td>Already done everywhere</td><td>—</td><td>—</td><td>—</td><td></td></tr></tbody></table></div>
<h2>7. Baseline profile</h2>
<p>Today: a hand seed of 6 lines (<code>app/src/optimized/baselineProfiles/baseline-prof.txt</code>), no generator and no Macrobenchmark. The box is at <code>speed</code> only because it was forced with <code>cmd package compile</code>. Every new install starts at <code>verify</code> (interpreted/JIT) until background dexopt runs.</p>
<div class="tw"><table class="num"><thead><tr><th>State (same box, earlier tv-perf run)</th><th>Cold start</th><th>Home scroll janky / p50 / p90</th><th>Movies grid</th></tr></thead><tbody><tr><td>Opt just installed (verify)</td><td>0.5–1.0 s</td><td>51.4 % / 65 / 117 ms</td><td>—</td></tr><tr><td>Opt after forced AOT (speed)</td><td>0.39–0.44 s</td><td>29.2 % / 38 / 61 ms</td><td>17.7 % / 19 / 36 ms</td></tr></tbody></table></div>
<p><b>Expected from a recorded profile</b> (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 <b>not</b> change steady state on a box already at <code>speed</code>. Effort M: the journey can run on an emulator in the build container; the box itself isn't needed. Risk low.</p>
<h2>8. Recommended plan</h2>
<ol class="plan">
<li><b>DNS and launch (TV, S, ~1 day).</b> Persisted DNS answers plus parallel connection warm-up at process start. Show the picker from saved people and check the session in the background. <i>Prove it:</i> 6 cold launches spaced &gt;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.</li>
<li><b>Image sizes (TV, XS, hours).</b> Sized WebP grid posters, TMDB w342, lean episode fields. <i>Prove it:</i> first Movies visit settles ≤400 ms (today 750); tank payload table.</li>
<li><b>Load states and motion (TV, M, 2–3 days, against your pick on this page).</b> Breathing shaped skeletons, art fade-in for network loads only, reserved late-line slots, staged reveal, hero cross-fade, reduced motion. <i>Prove it:</i> recordings like the ones above, no layout change after first content, gfxinfo janky no worse than today.</li>
<li><b>Prefetch and memory cache (TV, M, 2 days).</b> Dwell prefetch of title pages, a memory layer over the Conductarr cache, pre-decoded Home art behind the picker. <i>Prove it:</i> title first content ≤100 ms and Home settled ≤250 ms.</li>
<li><b>Conductarr release (M–L, 2–4 days):</b> held-title cache with webhook invalidation, readiness without duplicate reads or per-episode GETs, 304 before render, token-check cache. <b>Caddy</b>: compress the TV door. <i>Prove it:</i> 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.</li>
<li><b>Baseline profile (TV build, M, 1–2 days).</b> Recorded journey profile. <i>Prove it:</i> fresh install without a forced compile, Home scroll janky ≤30 %, cold start ≤0.5 s.</li>
<li><b>Frame cost (TV, M, 2–3 days).</b> The Compose fixes in section 6 and fewer full-screen layers. <i>Prove it:</i> Home and grid scroll janky ≤20 %, GPU p90 ≤16 ms in framestats.</li>
</ol>
<p><b>Total:</b> 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.</p>
<p class="muted small">A box-side alternative to step 1's DNS part: switch the box's Private DNS from <code>dns.adguard.com</code> 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.</p>
<h2>9. Your picks (all of them on one page)</h2>
<p class="muted">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.</p>
<form class="decision" data-decision-key="tv-motion-speed-direction" data-lavish-question="tv-motion-speed-direction"><h3>1. Load-state and motion direction</h3>
<p class="muted">Decides plan step 3 (skeletons and motion). The prototypes above show each part.</p>
<label class="opt"><input required type="radio" name="tv-motion-speed-direction" value="recommended" data-choice-key="tv-motion-speed-direction"> <b>A · As recommended</b> <span class="rec">recommended</span><span class="t">Breathing shaped skeletons, art fades in only when it came from the network, reserved space for late lines, hero cross-fade, reduced-motion safe.</span></label>
<label class="opt"><input required type="radio" name="tv-motion-speed-direction" value="flat" data-choice-key="tv-motion-speed-direction"> <b>B · Same, but flat skeletons</b><span class="t">Still card-shaped, no pulse. The calmest option.</span></label>
<label class="opt"><input required type="radio" name="tv-motion-speed-direction" value="shimmer" data-choice-key="tv-motion-speed-direction"> <b>C · Same, but shimmer skeletons</b><span class="t">A light sweep across the tiles. Costs GPU on this box every frame while loading.</span></label>
<label class="opt"><input required type="radio" name="tv-motion-speed-direction" value="changes" data-choice-key="tv-motion-speed-direction"> <b>D · Different</b><span class="t">Say what in the note: a prototype to change, a speed, an option to drop.</span></label>
<textarea aria-label="Notes for 1. Load-state and motion direction" name="tv-motion-speed-direction-note" placeholder="Optional note"></textarea><p><button type="submit">Queue this answer</button> <span role="status" aria-live="polite"></span></p></form>
<form class="decision" data-decision-key="tv-speed-quick-fixes" data-lavish-question="tv-speed-quick-fixes"><h3>2. The two quick speed fixes (about a day and a half, TV only)</h3>
<p class="muted">(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.</p>
<label class="opt"><input required type="radio" name="tv-speed-quick-fixes" value="go" data-choice-key="tv-speed-quick-fixes"> <b>Go, start now</b> <span class="rec">recommended</span><span class="t">Two small lanes, merged and staged for the next install.</span></label>
<label class="opt"><input required type="radio" name="tv-speed-quick-fixes" value="a-only" data-choice-key="tv-speed-quick-fixes"> <b>Only (a), the launch spinner</b><span class="t">Leave the image sizing for later.</span></label>
<label class="opt"><input required type="radio" name="tv-speed-quick-fixes" value="b-only" data-choice-key="tv-speed-quick-fixes"> <b>Only (b), the image sizes</b><span class="t">Leave the DNS cache for later.</span></label>
<label class="opt"><input required type="radio" name="tv-speed-quick-fixes" value="later" data-choice-key="tv-speed-quick-fixes"> <b>Not now</b><span class="t">Skip until after the motion work.</span></label>
<textarea aria-label="Notes for 2. The two quick speed fixes (about a day and a half, TV only)" name="tv-speed-quick-fixes-note" placeholder="Optional note"></textarea><p><button type="submit">Queue this answer</button> <span role="status" aria-live="polite"></span></p></form>
<form class="decision" data-decision-key="tv-box-dns" data-lavish-question="tv-box-dns"><h3>3. Change the box Private DNS (your call, system setting)</h3>
<p class="muted">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.</p>
<label class="opt"><input required type="radio" name="tv-box-dns" value="leave" data-choice-key="tv-box-dns"> <b>Leave it as it is</b> <span class="rec">recommended</span><span class="t">The TV-side cache covers the launch spinner.</span></label>
<label class="opt"><input required type="radio" name="tv-box-dns" value="automatic" data-choice-key="tv-box-dns"> <b>Switch to Automatic</b><span class="t">I change it when you free the TV and show before and after timings.</span></label>
<label class="opt"><input required type="radio" name="tv-box-dns" value="ask" data-choice-key="tv-box-dns"> <b>Ask me when the TV is free</b><span class="t">Decide then.</span></label>
<textarea aria-label="Notes for 3. Change the box Private DNS (your call, system setting)" name="tv-box-dns-note" placeholder="Optional note"></textarea><p><button type="submit">Queue this answer</button> <span role="status" aria-live="polite"></span></p></form>
<form class="decision" data-decision-key="tv-speed-later-steps" data-lavish-question="tv-speed-later-steps"><h3>4. Later plan steps to queue (TV, after the build above)</h3>
<p class="muted">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.</p>
<label class="opt"><input required type="radio" name="tv-speed-later-steps" value="all" data-choice-key="tv-speed-later-steps"> <b>Queue all three in that order</b> <span class="rec">recommended</span><span class="t">They follow the motion work, one lane at a time.</span></label>
<label class="opt"><input required type="radio" name="tv-speed-later-steps" value="prefetch" data-choice-key="tv-speed-later-steps"> <b>Only prefetch and memory cache</b><span class="t">Skip the profile and frame work for now.</span></label>
<label class="opt"><input required type="radio" name="tv-speed-later-steps" value="none" data-choice-key="tv-speed-later-steps"> <b>None yet</b><span class="t">Revisit after you have seen the motion build on the TV.</span></label>
<textarea aria-label="Notes for 4. Later plan steps to queue (TV, after the build above)" name="tv-speed-later-steps-note" placeholder="Optional note"></textarea><p><button type="submit">Queue this answer</button> <span role="status" aria-live="polite"></span></p></form>
<form class="decision" data-decision-key="tv-conductarr-retrieval" data-lavish-question="tv-conductarr-retrieval"><h3>5. Conductarr title-read speed-up (needs a Conductarr release)</h3>
<p class="muted">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).</p>
<label class="opt"><input required type="radio" name="tv-conductarr-retrieval" value="scope" data-choice-key="tv-conductarr-retrieval"> <b>Scope it first, then I decide</b> <span class="rec">recommended</span><span class="t">A short scout writes the exact change list and risks for a release; nothing ships.</span></label>
<label class="opt"><input required type="radio" name="tv-conductarr-retrieval" value="go" data-choice-key="tv-conductarr-retrieval"> <b>Go: build it and release it</b><span class="t">You are giving the word to build and release Conductarr when it is green.</span></label>
<label class="opt"><input required type="radio" name="tv-conductarr-retrieval" value="later" data-choice-key="tv-conductarr-retrieval"> <b>Not now</b><span class="t">Skip until after the TV-side wins.</span></label>
<textarea aria-label="Notes for 5. Conductarr title-read speed-up (needs a Conductarr release)" name="tv-conductarr-retrieval-note" placeholder="Optional note"></textarea><p><button type="submit">Queue this answer</button> <span role="status" aria-live="polite"></span></p></form>
<form class="decision" data-decision-key="tv-build-order" data-lavish-question="tv-build-order"><h3>6. When should these land on the TV?</h3>
<p class="muted">You said you will not be using the TV during this session.</p>
<label class="opt"><input required type="radio" name="tv-build-order" value="as-ready" data-choice-key="tv-build-order"> <b>Bundle with the next install</b> <span class="rec">recommended</span><span class="t">The speed fixes and any approved motion work join the staged build; I install when you say the TV is free.</span></label>
<label class="opt"><input required type="radio" name="tv-build-order" value="separate" data-choice-key="tv-build-order"> <b>Install the speed fixes on their own first</b><span class="t">A quick build for steps 2(a) and 2(b) only, installed as soon as it is ready if you free the TV.</span></label>
<textarea aria-label="Notes for 6. When should these land on the TV?" name="tv-build-order-note" placeholder="Optional note"></textarea><p><button type="submit">Queue this answer</button> <span role="status" aria-live="polite"></span></p></form>
<p class="muted small">Method, raw numbers and files: <code>data/tv-spike-motion-speed/report.md</code>. Nothing was changed in the app or on the server. The box was left on the Movies tab.</p>
<script>
// 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 `<div class="nav"><span class="wm">Wolf<b>skin</b></span>${items.map((n, i) => `<span class="ni ${i === cur ? 'cur' : ''} ${i === focus ? 'f' : ''}">${n}</span>`).join('')}<span class="nsp"></span><span class="av">S</span></div>`;
}
function rate(rt, pc) { return `<span class="rate"><i></i>${rt}</span><span class="rate"><i class="pc"></i>${pc}</span>`; }
function heroCopy(o) {
return `<div class="copy" style="bottom:${o.bottom || 330}px"><div class="kicker">${o.kicker}</div><div class="ht">${o.title}</div><div class="hm">${o.meta}</div>${o.ov ? `<div class="ho">${o.ov}</div>` : ''}<div class="btns">${o.btns}</div></div>`;
}
// ---------- 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(`<div class="abs" style="inset:0;background:url(${A}launcher.webp) center/cover"></div>`));
const bg = s.appendChild(html(`<div class="abs" style="inset:0;background:#07070a"></div>`));
T.a(bg, [{ opacity: 0 }, { opacity: 1 }], 0, 160);
const pk = s.appendChild(html(`<div class="abs" style="inset:0"><div class="abs" style="left:96px;top:60px;font-size:36px;font-weight:800">Wolf<b style="color:var(--accent)">skin</b></div><div class="pk">Who’s watching?</div>
${[['J', '#64c3ff', 'justin', 540], ['M', '#ffa47a', 'mindel', 850], ['S', '#9be38f', 'shared', 1160]].map(([l, c, n, x]) => `<div class="person ${n === 'shared' ? 'ring' : ''}" style="left:${x}px;background:${c}">${l}</div><div class="pname" style="left:${x}px">${n}</div>`).join('')}</div>`));
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(`<div class="abs" style="inset:0">
<div class="amb" style="background-image:url(${A}b2t.webp);filter:blur(30px);transform:scale(1.1)"></div>
<div class="bd" style="background-image:url(${A}b2.webp)"></div><div class="scrim"></div>
${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: '<span class="btn pri f">▶ Resume</span><span class="btn">ⓘ Details</span>', bottom: 340 })}
<div class="sh" style="top:780px">Next up</div>
${[3, 4, 1, 2].map((b, i) => `<div class="card tile" style="left:${96 + i * 440}px;top:850px"><div class="img" style="background-image:url(${A}b${b}.webp)"></div><div class="cap">${['American Gangster', 'Bohemian Rhapsody', 'Apocalypse Now', 'Arrival'][i]}</div></div>`).join('')}
${nav(0)}</div>`));
const picker = s.appendChild(html(`<div class="abs" style="inset:0;background:#07070a;z-index:10"><div class="pk">Who’s watching?</div>
${[['J', '#64c3ff', 'justin', 540], ['M', '#ffa47a', 'mindel', 850], ['S', '#9be38f', 'shared', 1160]].map(([l, c, n, x]) => `<div class="person ${n === 'shared' ? 'ring' : ''}" style="left:${x}px;background:${c}">${l}</div><div class="pname" style="left:${x}px">${n}</div>`).join('')}</div>`));
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(`<div class="abs" style="left:96px;top:150px;font-size:72px;font-weight:800;letter-spacing:-.02em">Movies <span style="font-size:30px;font-weight:500;color:var(--muted)">85 films</span></div>`));
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('<div class="spin"></div>')); T.a(sp, [{ opacity: 1 }, { opacity: 0 }], 900, 120); }
cells.forEach(([x, y, i]) => {
if (v !== 'spinner') {
const sk = s.appendChild(html(`<div class="card poster sk ${v === 'breathe' ? 'breathe' : v === 'shimmer' ? 'shim' : ''}" style="left:${x}px;top:${y}px;${v === 'breathe' ? `animation-delay:${(i % 6) * 90}ms` : ''}"></div>`));
T.a(sk, [{ opacity: 1 }, { opacity: 0 }], 900 + i * 25, 160);
}
const c = s.appendChild(html(`<div class="card poster" style="left:${x}px;top:${y}px;background-image:url(${poster(i)})"></div>`));
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(`<div class="abs" style="inset:0">
<div class="bd" style="background-image:url(${A}b4.webp)"></div><div class="scrim"></div>
${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: '<span class="btn">ⓘ Details</span>', bottom: 520 })}
<div class="sh" style="top:600px">Movies for you</div>
${[6, 7, 8, 9, 10, 11].map((p, i) => `<div class="card poster" style="left:${96 + i * 296}px;top:670px"><div class="img" style="background-image:url(${poster(p)})"></div></div>`).join('')}
</div>`));
const home = s.appendChild(html(`<div class="abs" style="inset:0"><div class="bd" style="background-image:url(${A}b2.webp)"></div><div class="scrim"></div>
${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: '<span class="btn pri">▶ Resume</span><span class="btn">ⓘ Details</span>', bottom: 340 })}</div>`));
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(`<div class="abs" style="inset:0">
<div class="amb" style="background-image:url(${poster(2)});filter:blur(60px) saturate(1.2) brightness(.55);transform:scale(1.2)"></div>
<div class="bd" style="background-image:url(${A}b1.webp)"></div><div class="scrim"></div>
<div class="copy" style="bottom:360px"><div class="kicker">✓ Movie · In your library</div><div class="ht">Apocalypse Now</div>
<div class="hm">1979 · 2h 27m ${rate('91%', '94%')} <span class="late" style="color:var(--muted)">Drama, War</span></div>
<div class="ho late2" style="min-height:76px">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.”</div>
<div class="btns"><span class="btn pri f">▶ Play</span><span class="btn">⋯</span><span class="btn">Add for someone</span></div></div>
<div class="sh" style="top:830px">Cast</div>
${[0, 1, 2, 3, 4, 5, 6].map(i => `<div class="circle" style="left:${96 + i * 244}px;top:900px"><span class="sk breathe abs" style="inset:0;border-radius:50%;animation-delay:${i * 90}ms"></span><div class="img" style="background-image:url(${A}c${i + 1}.webp)"></div></div>`).join('')}
${nav(2)}</div>`));
const grid = s.appendChild(html(`<div class="abs" style="inset:0;background:#07070a">${nav(2)}
<div class="abs" style="left:96px;top:150px;font-size:72px;font-weight:800;letter-spacing:-.02em">Movies <span style="font-size:30px;font-weight:500;color:var(--muted)">85 films</span></div>
${[0, 1, 2, 3, 4, 5].map(i => `<div class="card poster ${i === 2 ? 'ring' : ''}" style="left:${96 + i * 296}px;top:330px;background-image:url(${poster(i)});${i === 2 ? 'transform:scale(1.08)' : ''}"></div>`).join('')}
${[6, 7, 8, 9, 10, 11].map((p, i) => `<div class="card poster" style="left:${96 + i * 296}px;top:790px;background-image:url(${poster(p)})"></div>`).join('')}</div>`));
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(`<div class="abs" style="inset:0">
<div class="amb" style="background-image:url(${A}s1t.webp);filter:blur(30px);transform:scale(1.1)"></div>
<div class="bd" style="background-image:url(${A}s1.webp)"></div><div class="scrim"></div>
<div class="copy" style="bottom:430px"><div class="kicker">✓ Series · In your library · Watched</div><div class="ht">Adults</div>
<div class="hm">2025 · 2 seasons · Comedy <span class="lr">${rate('88%', '93%')}</span></div>
<div class="hm lr2" style="color:var(--ok,#9be38f);font-size:26px">✓ Ready for you · by Mindel</div>
<div class="ho" style="min-height:76px">A group of twenty-somethings in New York trying to be good people, despite being neither “good” nor “people” yet.</div>
<div class="btns"><span class="btn pri f">▶ Play S1 E1</span><span class="btn">⋯</span><span class="btn">Seasons</span></div></div>
<div class="abs" style="left:96px;top:790px;display:flex;gap:16px"><span class="btn" style="height:56px;font-size:24px;background:var(--accent-soft,#2a1f3d)">Season 1</span><span class="btn" style="height:56px;font-size:24px">Season 2</span></div>
${[0, 1, 2, 3].map(i => `<div class="card tile sk breathe" style="left:${96 + i * 440}px;top:880px;animation-delay:${i * 90}ms"><div class="img" style="background-image:url(${A}${i % 2 ? 's1' : 'e1'}.webp)"></div></div>`).join('')}
${nav(3)}</div>`));
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(`<div class="sh" style="top:250px">Movies for you</div>`));
const dur = v === 'today' ? 140 : 180, ease = v === 'today' ? EASE : OUT;
const cards = [0, 1, 2, 3, 4, 5].map(i => s.appendChild(html(`<div class="card poster" style="left:${96 + i * 296}px;top:330px;background-image:url(${poster(i + 6)})"></div>`)));
const caps = [0, 1, 2, 3, 4, 5].map(i => s.appendChild(html(`<div class="abs" style="left:${96 + i * 296}px;top:740px;width:520px"><div style="font-size:30px;font-weight:700">${POSTERS[i + 6][0]}</div><div style="font-size:26px;color:var(--muted)">${POSTERS[i + 6][1]}</div></div>`)));
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(`<div class="abs hero" style="inset:0"><div class="bd" style="background-image:url(${A}b${b}.webp)"></div><div class="scrim"></div>${heroCopy({ kicker: '✦ For you', title: t, meta, ov, btns: '<span class="btn">ⓘ Details</span>', bottom: 520 })}</div>`));
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(`<div class="abs" style="left:96px;top:600px;font-size:34px;font-weight:700">Movies for you</div>`));
[0, 1, 2, 3, 4, 5].map(i => s.append(html(`<div class="card poster" style="left:${96 + i * 296}px;top:670px;background-image:url(${poster(i)})"></div>`)));
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('<div class="stage"></div>'); box.appendChild(stage);
label = stage.appendChild(html('<div class="tl">0 ms</div>'));
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';
})();
document.querySelectorAll('form.decision').forEach(form=>{form.addEventListener('submit',event=>{event.preventDefault();const key=form.dataset.lavishQuestion;const answer=new FormData(form).get(key);const status=form.querySelector('[role=status]');if(!answer){status.textContent='Pick an option first.';return;}if(!window.lavish||!window.lavish.queuePrompt){status.textContent='Open this page in the live review board to queue an answer.';return;}const note=form.querySelector('textarea').value.trim();const label=form.querySelector('h3').textContent;window.lavish.queuePrompt(label+': '+answer+(note?'\n'+note:''),{tag:'choice',text:label+': '+answer,element:form,queueKey:key,data:{question:key,answer:answer,note:note}});status.textContent='Queued. Press Send to Agent in the review toolbar.';});});
</script></main></body></html>

View file

@ -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}

View file

@ -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 `<div class="nav"><span class="wm">Wolf<b>skin</b></span>${items.map((n, i) => `<span class="ni ${i === cur ? 'cur' : ''} ${i === focus ? 'f' : ''}">${n}</span>`).join('')}<span class="nsp"></span><span class="av">S</span></div>`;
}
function rate(rt, pc) { return `<span class="rate"><i></i>${rt}</span><span class="rate"><i class="pc"></i>${pc}</span>`; }
function heroCopy(o) {
return `<div class="copy" style="bottom:${o.bottom || 330}px"><div class="kicker">${o.kicker}</div><div class="ht">${o.title}</div><div class="hm">${o.meta}</div>${o.ov ? `<div class="ho">${o.ov}</div>` : ''}<div class="btns">${o.btns}</div></div>`;
}
// ---------- 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(`<div class="abs" style="inset:0;background:url(${A}launcher.webp) center/cover"></div>`));
const bg = s.appendChild(html(`<div class="abs" style="inset:0;background:#07070a"></div>`));
T.a(bg, [{ opacity: 0 }, { opacity: 1 }], 0, 160);
const pk = s.appendChild(html(`<div class="abs" style="inset:0"><div class="abs" style="left:96px;top:60px;font-size:36px;font-weight:800">Wolf<b style="color:var(--accent)">skin</b></div><div class="pk">Who’s watching?</div>
${[['J', '#64c3ff', 'justin', 540], ['M', '#ffa47a', 'mindel', 850], ['S', '#9be38f', 'shared', 1160]].map(([l, c, n, x]) => `<div class="person ${n === 'shared' ? 'ring' : ''}" style="left:${x}px;background:${c}">${l}</div><div class="pname" style="left:${x}px">${n}</div>`).join('')}</div>`));
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(`<div class="abs" style="inset:0">
<div class="amb" style="background-image:url(${A}b2t.webp);filter:blur(30px);transform:scale(1.1)"></div>
<div class="bd" style="background-image:url(${A}b2.webp)"></div><div class="scrim"></div>
${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: '<span class="btn pri f">▶ Resume</span><span class="btn">ⓘ Details</span>', bottom: 340 })}
<div class="sh" style="top:780px">Next up</div>
${[3, 4, 1, 2].map((b, i) => `<div class="card tile" style="left:${96 + i * 440}px;top:850px"><div class="img" style="background-image:url(${A}b${b}.webp)"></div><div class="cap">${['American Gangster', 'Bohemian Rhapsody', 'Apocalypse Now', 'Arrival'][i]}</div></div>`).join('')}
${nav(0)}</div>`));
const picker = s.appendChild(html(`<div class="abs" style="inset:0;background:#07070a;z-index:10"><div class="pk">Who’s watching?</div>
${[['J', '#64c3ff', 'justin', 540], ['M', '#ffa47a', 'mindel', 850], ['S', '#9be38f', 'shared', 1160]].map(([l, c, n, x]) => `<div class="person ${n === 'shared' ? 'ring' : ''}" style="left:${x}px;background:${c}">${l}</div><div class="pname" style="left:${x}px">${n}</div>`).join('')}</div>`));
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(`<div class="abs" style="left:96px;top:150px;font-size:72px;font-weight:800;letter-spacing:-.02em">Movies <span style="font-size:30px;font-weight:500;color:var(--muted)">85 films</span></div>`));
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('<div class="spin"></div>')); T.a(sp, [{ opacity: 1 }, { opacity: 0 }], 900, 120); }
cells.forEach(([x, y, i]) => {
if (v !== 'spinner') {
const sk = s.appendChild(html(`<div class="card poster sk ${v === 'breathe' ? 'breathe' : v === 'shimmer' ? 'shim' : ''}" style="left:${x}px;top:${y}px;${v === 'breathe' ? `animation-delay:${(i % 6) * 90}ms` : ''}"></div>`));
T.a(sk, [{ opacity: 1 }, { opacity: 0 }], 900 + i * 25, 160);
}
const c = s.appendChild(html(`<div class="card poster" style="left:${x}px;top:${y}px;background-image:url(${poster(i)})"></div>`));
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(`<div class="abs" style="inset:0">
<div class="bd" style="background-image:url(${A}b4.webp)"></div><div class="scrim"></div>
${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: '<span class="btn">ⓘ Details</span>', bottom: 520 })}
<div class="sh" style="top:600px">Movies for you</div>
${[6, 7, 8, 9, 10, 11].map((p, i) => `<div class="card poster" style="left:${96 + i * 296}px;top:670px"><div class="img" style="background-image:url(${poster(p)})"></div></div>`).join('')}
</div>`));
const home = s.appendChild(html(`<div class="abs" style="inset:0"><div class="bd" style="background-image:url(${A}b2.webp)"></div><div class="scrim"></div>
${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: '<span class="btn pri">▶ Resume</span><span class="btn">ⓘ Details</span>', bottom: 340 })}</div>`));
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(`<div class="abs" style="inset:0">
<div class="amb" style="background-image:url(${poster(2)});filter:blur(60px) saturate(1.2) brightness(.55);transform:scale(1.2)"></div>
<div class="bd" style="background-image:url(${A}b1.webp)"></div><div class="scrim"></div>
<div class="copy" style="bottom:360px"><div class="kicker">✓ Movie · In your library</div><div class="ht">Apocalypse Now</div>
<div class="hm">1979 · 2h 27m ${rate('91%', '94%')} <span class="late" style="color:var(--muted)">Drama, War</span></div>
<div class="ho late2" style="min-height:76px">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.”</div>
<div class="btns"><span class="btn pri f">▶ Play</span><span class="btn">⋯</span><span class="btn">Add for someone</span></div></div>
<div class="sh" style="top:830px">Cast</div>
${[0, 1, 2, 3, 4, 5, 6].map(i => `<div class="circle" style="left:${96 + i * 244}px;top:900px"><span class="sk breathe abs" style="inset:0;border-radius:50%;animation-delay:${i * 90}ms"></span><div class="img" style="background-image:url(${A}c${i + 1}.webp)"></div></div>`).join('')}
${nav(2)}</div>`));
const grid = s.appendChild(html(`<div class="abs" style="inset:0;background:#07070a">${nav(2)}
<div class="abs" style="left:96px;top:150px;font-size:72px;font-weight:800;letter-spacing:-.02em">Movies <span style="font-size:30px;font-weight:500;color:var(--muted)">85 films</span></div>
${[0, 1, 2, 3, 4, 5].map(i => `<div class="card poster ${i === 2 ? 'ring' : ''}" style="left:${96 + i * 296}px;top:330px;background-image:url(${poster(i)});${i === 2 ? 'transform:scale(1.08)' : ''}"></div>`).join('')}
${[6, 7, 8, 9, 10, 11].map((p, i) => `<div class="card poster" style="left:${96 + i * 296}px;top:790px;background-image:url(${poster(p)})"></div>`).join('')}</div>`));
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(`<div class="abs" style="inset:0">
<div class="amb" style="background-image:url(${A}s1t.webp);filter:blur(30px);transform:scale(1.1)"></div>
<div class="bd" style="background-image:url(${A}s1.webp)"></div><div class="scrim"></div>
<div class="copy" style="bottom:430px"><div class="kicker">✓ Series · In your library · Watched</div><div class="ht">Adults</div>
<div class="hm">2025 · 2 seasons · Comedy <span class="lr">${rate('88%', '93%')}</span></div>
<div class="hm lr2" style="color:var(--ok,#9be38f);font-size:26px">✓ Ready for you · by Mindel</div>
<div class="ho" style="min-height:76px">A group of twenty-somethings in New York trying to be good people, despite being neither “good” nor “people” yet.</div>
<div class="btns"><span class="btn pri f">▶ Play S1 E1</span><span class="btn">⋯</span><span class="btn">Seasons</span></div></div>
<div class="abs" style="left:96px;top:790px;display:flex;gap:16px"><span class="btn" style="height:56px;font-size:24px;background:var(--accent-soft,#2a1f3d)">Season 1</span><span class="btn" style="height:56px;font-size:24px">Season 2</span></div>
${[0, 1, 2, 3].map(i => `<div class="card tile sk breathe" style="left:${96 + i * 440}px;top:880px;animation-delay:${i * 90}ms"><div class="img" style="background-image:url(${A}${i % 2 ? 's1' : 'e1'}.webp)"></div></div>`).join('')}
${nav(3)}</div>`));
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(`<div class="sh" style="top:250px">Movies for you</div>`));
const dur = v === 'today' ? 140 : 180, ease = v === 'today' ? EASE : OUT;
const cards = [0, 1, 2, 3, 4, 5].map(i => s.appendChild(html(`<div class="card poster" style="left:${96 + i * 296}px;top:330px;background-image:url(${poster(i + 6)})"></div>`)));
const caps = [0, 1, 2, 3, 4, 5].map(i => s.appendChild(html(`<div class="abs" style="left:${96 + i * 296}px;top:740px;width:520px"><div style="font-size:30px;font-weight:700">${POSTERS[i + 6][0]}</div><div style="font-size:26px;color:var(--muted)">${POSTERS[i + 6][1]}</div></div>`)));
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(`<div class="abs hero" style="inset:0"><div class="bd" style="background-image:url(${A}b${b}.webp)"></div><div class="scrim"></div>${heroCopy({ kicker: '✦ For you', title: t, meta, ov, btns: '<span class="btn">ⓘ Details</span>', bottom: 520 })}</div>`));
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(`<div class="abs" style="left:96px;top:600px;font-size:34px;font-weight:700">Movies for you</div>`));
[0, 1, 2, 3, 4, 5].map(i => s.append(html(`<div class="card poster" style="left:${96 + i * 296}px;top:670px;background-image:url(${poster(i)})"></div>`)));
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('<div class="stage"></div>'); box.appendChild(stage);
label = stage.appendChild(html('<div class="tl">0 ms</div>'));
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';
})();

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 244 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 204 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 388 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 522 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Some files were not shown because too many files have changed in this diff Show more