Compare commits
No commits in common. "main" and "fm/tv-person-switch-fix" have entirely different histories.
main
...
fm/tv-pers
|
|
@ -133,13 +133,6 @@ configure<ApplicationExtension> {
|
||||||
isDebuggable = true
|
isDebuggable = true
|
||||||
applicationIdSuffix = ".debug"
|
applicationIdSuffix = ".debug"
|
||||||
}
|
}
|
||||||
|
|
||||||
create("optimized") {
|
|
||||||
initWith(getByName("release"))
|
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
|
||||||
applicationIdSuffix = ".opt"
|
|
||||||
matchingFallbacks += "release"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
flavorDimensions += "version"
|
flavorDimensions += "version"
|
||||||
productFlavors {
|
productFlavors {
|
||||||
|
|
@ -206,10 +199,6 @@ configure<ApplicationExtension> {
|
||||||
kotlin.directories += "$buildDir/generated/seerr_api/src/main/kotlin"
|
kotlin.directories += "$buildDir/generated/seerr_api/src/main/kotlin"
|
||||||
kotlin.directories += "src/wolfskin/java"
|
kotlin.directories += "src/wolfskin/java"
|
||||||
}
|
}
|
||||||
getByName("optimized") {
|
|
||||||
kotlin.directories += "src/release/java"
|
|
||||||
baselineProfiles.directories += "src/optimized/baselineProfiles"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
testOptions {
|
testOptions {
|
||||||
|
|
@ -239,11 +228,6 @@ androidComponents {
|
||||||
"Wholphin-${variant.flavorName}-${variant.buildType}-${output.versionName.get()}-${output.versionCode.get()}$abi.apk"
|
"Wholphin-${variant.flavorName}-${variant.buildType}-${output.versionName.get()}-${output.versionCode.get()}$abi.apk"
|
||||||
output.outputFileName = outputFileName
|
output.outputFileName = outputFileName
|
||||||
}
|
}
|
||||||
if (variant.name == "defaultOptimized") {
|
|
||||||
tasks.withType<Test>().configureEach {
|
|
||||||
systemProperty("wolfskin.optimizedVersionName", variant.outputs.first().versionName.get())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (variant.name == "defaultDebug") {
|
if (variant.name == "defaultDebug") {
|
||||||
val packagedApks = variant.artifacts.get(SingleArtifact.APK)
|
val packagedApks = variant.artifacts.get(SingleArtifact.APK)
|
||||||
val artifactLoader = variant.artifacts.getBuiltArtifactsLoader()
|
val artifactLoader = variant.artifacts.getBuiltArtifactsLoader()
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,6 @@
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:hasFragileUserData="true"
|
|
||||||
android:banner="@mipmap/ic_banner"
|
android:banner="@mipmap/ic_banner"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,6 @@ import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Surface
|
import androidx.tv.material3.Surface
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
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.AppPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
||||||
import com.github.damontecres.wholphin.services.AppUpgradeHandler
|
import com.github.damontecres.wholphin.services.AppUpgradeHandler
|
||||||
|
|
@ -577,22 +576,12 @@ class MainActivityViewModel
|
||||||
val restoredSession =
|
val restoredSession =
|
||||||
if (signInAutomatically) {
|
if (signInAutomatically) {
|
||||||
Timber.v("Sign in automatically")
|
Timber.v("Sign in automatically")
|
||||||
serverRepository.restoreSavedSession()
|
serverRepository.restoreLastSession()
|
||||||
} else {
|
} else {
|
||||||
serverRepository.getMostRecentServer()
|
serverRepository.getMostRecentServer()
|
||||||
}
|
}
|
||||||
Timber.d("Restore last session result=%s", restoredSession)
|
Timber.d("Restore last session result=%s", restoredSession)
|
||||||
setupNavigationManager.navigateTo(restoredSession.destination)
|
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@ import org.acra.ktx.initAcra
|
||||||
import org.jellyfin.sdk.Jellyfin
|
import org.jellyfin.sdk.Jellyfin
|
||||||
import org.jellyfin.sdk.model.ServerVersion
|
import org.jellyfin.sdk.model.ServerVersion
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import org.justinvisser.wolfskin.tv.NetworkStartup
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@OptIn(ExperimentalComposeRuntimeApi::class)
|
@OptIn(ExperimentalComposeRuntimeApi::class)
|
||||||
|
|
@ -55,7 +54,6 @@ class WholphinApplication :
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
networkStartup.start()
|
|
||||||
if (BuildConfig.DEBUG) {
|
if (BuildConfig.DEBUG) {
|
||||||
StrictMode.setThreadPolicy(
|
StrictMode.setThreadPolicy(
|
||||||
StrictMode.ThreadPolicy
|
StrictMode.ThreadPolicy
|
||||||
|
|
@ -112,9 +110,6 @@ class WholphinApplication :
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var workerFactory: HiltWorkerFactory
|
lateinit var workerFactory: HiltWorkerFactory
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var networkStartup: NetworkStartup
|
|
||||||
|
|
||||||
override val workManagerConfiguration: Configuration
|
override val workManagerConfiguration: Configuration
|
||||||
get() =
|
get() =
|
||||||
Configuration
|
Configuration
|
||||||
|
|
|
||||||
|
|
@ -51,10 +51,6 @@ interface JellyfinServerDao {
|
||||||
return user.copy(rowId = result.toInt())
|
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")
|
@Query("SELECT * FROM users WHERE serverId = :serverId AND id = :userId")
|
||||||
fun getUser(
|
fun getUser(
|
||||||
serverId: UUID,
|
serverId: UUID,
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,6 @@ import com.github.damontecres.wholphin.services.hilt.IoDispatcher
|
||||||
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.async
|
|
||||||
import kotlinx.coroutines.coroutineScope
|
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
|
@ -103,7 +101,15 @@ class ServerRepository
|
||||||
}
|
}
|
||||||
Timber.v("Changing user to ${user.name} on ${server.url}")
|
Timber.v("Changing user to ${user.name} on ${server.url}")
|
||||||
apiClient.update(baseUrl = server.url, accessToken = user.accessToken)
|
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 =
|
var updatedUser =
|
||||||
user.copy(
|
user.copy(
|
||||||
id = userDto.id,
|
id = userDto.id,
|
||||||
|
|
@ -119,45 +125,6 @@ class ServerRepository
|
||||||
return@withContext currentUser
|
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
|
* Try to restore the most recent session
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -24,13 +24,6 @@ class NavigationManager
|
||||||
log()
|
log()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Replace only the current page, preserving every parent entry. */
|
|
||||||
fun replaceTop(destination: Destination) {
|
|
||||||
if (backStack.isEmpty()) backStack.add(destination)
|
|
||||||
else backStack[backStack.lastIndex] = destination
|
|
||||||
log()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Go to the specified [Destination], but reset the back stack to Home first
|
* Go to the specified [Destination], but reset the back stack to Home first
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -26,9 +26,6 @@ import org.jellyfin.sdk.api.okhttp.OkHttpFactory
|
||||||
import org.jellyfin.sdk.createJellyfin
|
import org.jellyfin.sdk.createJellyfin
|
||||||
import org.jellyfin.sdk.model.ClientInfo
|
import org.jellyfin.sdk.model.ClientInfo
|
||||||
import org.jellyfin.sdk.model.DeviceInfo
|
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.Qualifier
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
|
@ -94,14 +91,12 @@ object AppModule {
|
||||||
@StandardOkHttpClient
|
@StandardOkHttpClient
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun okHttpClient(dns: PersistentDns) =
|
fun okHttpClient() =
|
||||||
DnsConnectionRetry.configure(OkHttpClient.Builder(), dns).build()
|
OkHttpClient
|
||||||
|
.Builder()
|
||||||
/** Resolve noBackupFilesDir lazily: Android may create it and lookup always runs off main. */
|
.apply {
|
||||||
@Provides
|
// TODO user agent, timeouts, logging, etc
|
||||||
@Singleton
|
}.build()
|
||||||
fun dns(@ApplicationContext context: Context): PersistentDns =
|
|
||||||
PersistentDns(file = { File(context.noBackupFilesDir, "wolfskin-dns.json") })
|
|
||||||
|
|
||||||
@AuthOkHttpClient
|
@AuthOkHttpClient
|
||||||
@Provides
|
@Provides
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,7 @@ import coil3.network.NetworkResponse
|
||||||
import coil3.network.cachecontrol.CacheControlCacheStrategy
|
import coil3.network.cachecontrol.CacheControlCacheStrategy
|
||||||
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||||
import coil3.request.Options
|
import coil3.request.Options
|
||||||
import coil3.request.transitionFactory
|
import coil3.request.crossfade
|
||||||
import org.justinvisser.wolfskin.design.NetworkArtTransition
|
|
||||||
import coil3.util.DebugLogger
|
import coil3.util.DebugLogger
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
|
|
@ -90,7 +89,7 @@ fun CoilConfig(
|
||||||
memoryCache(null)
|
memoryCache(null)
|
||||||
diskCache(null)
|
diskCache(null)
|
||||||
}
|
}
|
||||||
}.transitionFactory(NetworkArtTransition)
|
}.crossfade(false)
|
||||||
.logger(if (debugLogging) DebugLogger() else null)
|
.logger(if (debugLogging) DebugLogger() else null)
|
||||||
.components {
|
.components {
|
||||||
add(
|
add(
|
||||||
|
|
|
||||||
|
|
@ -82,11 +82,9 @@ class PlayExternalViewModel
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
val launched = savedStateHandle.getMutableStateFlow("launched", false)
|
val launched = savedStateHandle.getMutableStateFlow("launched", false)
|
||||||
val state = MutableStateFlow(PlayExternalState())
|
val state = MutableStateFlow(PlayExternalState())
|
||||||
private var watchScope: String? = null
|
|
||||||
|
|
||||||
fun init(destination: Destination) {
|
fun init(destination: Destination) {
|
||||||
Timber.v("init called: %s", destination)
|
Timber.v("init called: %s", destination)
|
||||||
watchScope = "${serverRepository.currentServer?.id}:${serverRepository.currentUser?.id}"
|
|
||||||
state.update { it.copy(loading = LoadingState.Loading) }
|
state.update { it.copy(loading = LoadingState.Loading) }
|
||||||
viewModelScope.launchDefault {
|
viewModelScope.launchDefault {
|
||||||
val prefs = userPreferencesService.getCurrent()
|
val prefs = userPreferencesService.getCurrent()
|
||||||
|
|
@ -360,7 +358,6 @@ class PlayExternalViewModel
|
||||||
failed = false,
|
failed = false,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
watchScope?.let { com.github.damontecres.wholphin.util.PlaybackWatchState.stopped(context, it) }
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Timber.w(
|
Timber.w(
|
||||||
|
|
|
||||||
|
|
@ -830,12 +830,10 @@ class PlaybackViewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
val playbackItemState = PlaybackItemState(playback)
|
val playbackItemState = PlaybackItemState(playback)
|
||||||
val watchScope = "${serverRepository.currentServer?.id}:${serverRepository.currentUser?.id}"
|
|
||||||
val activityListener =
|
val activityListener =
|
||||||
TrackActivityPlaybackListener(
|
TrackActivityPlaybackListener(
|
||||||
api = api,
|
api = api,
|
||||||
player = player,
|
player = player,
|
||||||
onStopped = { com.github.damontecres.wholphin.util.PlaybackWatchState.stopped(context, watchScope) },
|
|
||||||
getState = { playbackItemState },
|
getState = { playbackItemState },
|
||||||
)
|
)
|
||||||
player.addListener(activityListener)
|
player.addListener(activityListener)
|
||||||
|
|
|
||||||
|
|
@ -52,9 +52,6 @@ class SwitchSeerrViewModel
|
||||||
_state.update { it.copy(loading = LoadingState.Success) }
|
_state.update { it.copy(loading = LoadingState.Success) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// Nothing to check so set success
|
|
||||||
_state.update { it.copy(loading = LoadingState.Success) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,6 @@ import android.content.Context
|
||||||
import com.github.damontecres.wholphin.data.MostRecentServer
|
import com.github.damontecres.wholphin.data.MostRecentServer
|
||||||
import com.github.damontecres.wholphin.services.hilt.AppModule
|
import com.github.damontecres.wholphin.services.hilt.AppModule
|
||||||
import com.github.damontecres.wholphin.services.hilt.DeviceModule
|
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 com.google.auto.service.AutoService
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
|
|
@ -78,7 +73,10 @@ class CrashReportSender : ReportSender {
|
||||||
|
|
||||||
private fun MostRecentServer.createApi(context: Context): ApiClient? =
|
private fun MostRecentServer.createApi(context: Context): ApiClient? =
|
||||||
(this as? MostRecentServer.ServerAndUser)?.let {
|
(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 okHttpFactory = OkHttpFactory(okHttpClient)
|
||||||
val api =
|
val api =
|
||||||
createJellyfin {
|
createJellyfin {
|
||||||
|
|
@ -91,11 +89,3 @@ private fun MostRecentServer.createApi(context: Context): ApiClient? =
|
||||||
}.createApi(baseUrl = serverUrl, accessToken = accessToken)
|
}.createApi(baseUrl = serverUrl, accessToken = accessToken)
|
||||||
api
|
api
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Crash reporting shares the application's DNS hints without borrowing authenticated headers. */
|
|
||||||
@EntryPoint
|
|
||||||
@InstallIn(SingletonComponent::class)
|
|
||||||
interface CrashNetwork {
|
|
||||||
@StandardOkHttpClient
|
|
||||||
fun client(): OkHttpClient
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
package com.github.damontecres.wholphin.util
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import org.justinvisser.wolfskin.conductarr.FileCacheStore
|
|
||||||
import java.io.File
|
|
||||||
import java.io.IOException
|
|
||||||
import timber.log.Timber
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.flow.update
|
|
||||||
|
|
||||||
/** Only an acknowledged stop invalidates the captured server/user's watch projections. */
|
|
||||||
object PlaybackWatchState {
|
|
||||||
private val mutableRevisions = MutableStateFlow<Map<String, Long>>(emptyMap())
|
|
||||||
val revisions = mutableRevisions.asStateFlow()
|
|
||||||
|
|
||||||
/** Remove persisted watch grids before publishing the captured user's acknowledgement. */
|
|
||||||
fun stopped(context: Context, scope: String) {
|
|
||||||
try {
|
|
||||||
FileCacheStore(File(context.filesDir, "wolfskin-library-cache")).clear(scope)
|
|
||||||
} catch (exception: IOException) {
|
|
||||||
Timber.w(exception, "Could not invalidate playback watch snapshots")
|
|
||||||
}
|
|
||||||
val time = System.currentTimeMillis()
|
|
||||||
mutableRevisions.update { previous -> previous + (scope to maxOf(time, (previous[scope] ?: 0L) + 1L)) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -7,8 +7,6 @@ import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.playback.CurrentPlayback
|
import com.github.damontecres.wholphin.ui.playback.CurrentPlayback
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.sync.Mutex
|
|
||||||
import kotlinx.coroutines.sync.withLock
|
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import org.jellyfin.sdk.api.client.extensions.playStateApi
|
import org.jellyfin.sdk.api.client.extensions.playStateApi
|
||||||
import org.jellyfin.sdk.model.api.PlayMethod
|
import org.jellyfin.sdk.model.api.PlayMethod
|
||||||
|
|
@ -33,12 +31,9 @@ import kotlin.time.Duration.Companion.seconds
|
||||||
class TrackActivityPlaybackListener(
|
class TrackActivityPlaybackListener(
|
||||||
private val api: ApiClient,
|
private val api: ApiClient,
|
||||||
private val player: Player,
|
private val player: Player,
|
||||||
private val onStopped: () -> Unit = {},
|
|
||||||
private val getState: () -> PlaybackItemState?,
|
private val getState: () -> PlaybackItemState?,
|
||||||
) : Player.Listener {
|
) : Player.Listener {
|
||||||
private val coroutineScope = CoroutineScope(WholphinDispatchers.Main)
|
private val coroutineScope = CoroutineScope(WholphinDispatchers.Main)
|
||||||
private val reports = Mutex()
|
|
||||||
@Volatile private var released = false
|
|
||||||
private val task: TimerTask =
|
private val task: TimerTask =
|
||||||
object : TimerTask() {
|
object : TimerTask() {
|
||||||
override fun run() {
|
override fun run() {
|
||||||
|
|
@ -75,23 +70,19 @@ class TrackActivityPlaybackListener(
|
||||||
|
|
||||||
val delay = 5.seconds.inWholeMilliseconds
|
val delay = 5.seconds.inWholeMilliseconds
|
||||||
// Every x seconds, check if the video is playing
|
// Every x seconds, check if the video is playing
|
||||||
if (!released) {
|
TIMER.schedule(task, delay, delay)
|
||||||
TIMER.schedule(task, delay, delay)
|
initialized = true
|
||||||
initialized = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun release() {
|
fun release() {
|
||||||
if (released) return
|
// player.removeListener(this)
|
||||||
released = true
|
|
||||||
task.cancel()
|
task.cancel()
|
||||||
TIMER.purge()
|
TIMER.purge()
|
||||||
val position = player.currentPosition.milliseconds
|
val position = player.currentPosition.milliseconds
|
||||||
val stoppedState = getState.invoke()
|
|
||||||
launch("reportPlaybackStopped") {
|
launch("reportPlaybackStopped") {
|
||||||
stoppedState?.let { state ->
|
getState.invoke()?.let { state ->
|
||||||
Timber.v("reportPlaybackStopped for ${state.itemId} at $position")
|
Timber.v("reportPlaybackStopped for ${state.itemId} at $position")
|
||||||
if (position < Duration.ZERO) {
|
if (position < Duration.ZERO) {
|
||||||
Timber.w("Negative position when reporting playback stopped: %s", position)
|
Timber.w("Negative position when reporting playback stopped: %s", position)
|
||||||
|
|
@ -105,7 +96,6 @@ class TrackActivityPlaybackListener(
|
||||||
liveStreamId = state.liveStreamId,
|
liveStreamId = state.liveStreamId,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
onStopped()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -127,7 +117,6 @@ class TrackActivityPlaybackListener(
|
||||||
|
|
||||||
private fun saveActivity(position: Long) {
|
private fun saveActivity(position: Long) {
|
||||||
launch("saveActivity") {
|
launch("saveActivity") {
|
||||||
if (released) return@launch
|
|
||||||
getState.invoke()?.let { state ->
|
getState.invoke()?.let { state ->
|
||||||
val calcPosition =
|
val calcPosition =
|
||||||
withContext(WholphinDispatchers.Main) {
|
withContext(WholphinDispatchers.Main) {
|
||||||
|
|
@ -163,7 +152,7 @@ class TrackActivityPlaybackListener(
|
||||||
) {
|
) {
|
||||||
coroutineScope.launchIO {
|
coroutineScope.launchIO {
|
||||||
try {
|
try {
|
||||||
reports.withLock { block.invoke(this) }
|
block.invoke(this)
|
||||||
} catch (ex: Exception) {
|
} catch (ex: Exception) {
|
||||||
Timber.w(ex, "Exception during %s", name)
|
Timber.w(ex, "Exception during %s", name)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
# Hand-maintained startup/shell seed; supplement with device-recorded navigation profiles.
|
|
||||||
Lcom/github/damontecres/wholphin/WholphinApplication;
|
|
||||||
HSPLcom/github/damontecres/wholphin/WholphinApplication;->onCreate()V
|
|
||||||
Lcom/github/damontecres/wholphin/MainActivity;
|
|
||||||
HSPLcom/github/damontecres/wholphin/MainActivity;->onCreate(Landroid/os/Bundle;)V
|
|
||||||
Lorg/justinvisser/wolfskin/tv/WolfskinAppKt;
|
|
||||||
Lorg/justinvisser/wolfskin/tv/WolfskinScreenPreference;
|
|
||||||
Lorg/justinvisser/wolfskin/tv/ConductarrPreference;
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<string name="app_name" translatable="false">Wolfskin (Opt)</string>
|
|
||||||
</resources>
|
|
||||||
|
|
@ -32,8 +32,6 @@ import io.mockk.mockkStatic
|
||||||
import io.mockk.slot
|
import io.mockk.slot
|
||||||
import io.mockk.unmockkStatic
|
import io.mockk.unmockkStatic
|
||||||
import io.mockk.verify
|
import io.mockk.verify
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
|
||||||
import kotlinx.coroutines.test.runCurrent
|
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
|
|
@ -118,6 +116,9 @@ class TestMainActivityViewModel {
|
||||||
signInAutomatically = true
|
signInAutomatically = true
|
||||||
}
|
}
|
||||||
every { serverRepository.current } returns MutableStateFlow(currentUser)
|
every { serverRepository.current } returns MutableStateFlow(currentUser)
|
||||||
|
// coEvery { serverRepository.tryRestoreSession(serverId, userId) } returns currentUser
|
||||||
|
// coEvery { serverRepository.restoreLastSession() } returns
|
||||||
|
// RestoredSession.Success(currentUser)
|
||||||
|
|
||||||
viewModel.handleAppStart()
|
viewModel.handleAppStart()
|
||||||
|
|
||||||
|
|
@ -131,7 +132,7 @@ class TestMainActivityViewModel {
|
||||||
destination as SetupDestination.AppContent
|
destination as SetupDestination.AppContent
|
||||||
Assert.assertEquals(currentUser, destination.current)
|
Assert.assertEquals(currentUser, destination.current)
|
||||||
}
|
}
|
||||||
coVerify(exactly = 0) { serverRepository.restoreSavedSession() }
|
coVerify(exactly = 0) { serverRepository.restoreLastSession() }
|
||||||
coVerify(exactly = 0) { serverRepository.getMostRecentServer() }
|
coVerify(exactly = 0) { serverRepository.getMostRecentServer() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -149,7 +150,7 @@ class TestMainActivityViewModel {
|
||||||
viewModel.handleAppStart()
|
viewModel.handleAppStart()
|
||||||
|
|
||||||
coVerify(exactly = 0) { serverRepository.tryChangeUser(any(), any()) }
|
coVerify(exactly = 0) { serverRepository.tryChangeUser(any(), any()) }
|
||||||
coVerify(exactly = 0) { serverRepository.restoreSavedSession() }
|
coVerify(exactly = 0) { serverRepository.restoreLastSession() }
|
||||||
coVerify(exactly = 1) { serverRepository.getMostRecentServer() }
|
coVerify(exactly = 1) { serverRepository.getMostRecentServer() }
|
||||||
val args = mutableListOf<SetupDestination>()
|
val args = mutableListOf<SetupDestination>()
|
||||||
verify { setupNavigationManager.navigateTo(capture(args)) }
|
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
|
@Test
|
||||||
fun `Test auto sign in, hot load, with protected profile`() =
|
fun `Test auto sign in, hot load, with protected profile`() =
|
||||||
runTest {
|
runTest {
|
||||||
|
|
@ -191,7 +178,7 @@ class TestMainActivityViewModel {
|
||||||
coEvery {
|
coEvery {
|
||||||
serverRepository.tryChangeUser(serverId, userId)
|
serverRepository.tryChangeUser(serverId, userId)
|
||||||
} returns null
|
} returns null
|
||||||
coEvery { serverRepository.restoreSavedSession() } returns
|
coEvery { serverRepository.restoreLastSession() } returns
|
||||||
RestoredSession.ServerOnly(server)
|
RestoredSession.ServerOnly(server)
|
||||||
|
|
||||||
viewModel.handleAppStart()
|
viewModel.handleAppStart()
|
||||||
|
|
@ -217,7 +204,7 @@ class TestMainActivityViewModel {
|
||||||
signInAutomatically = true
|
signInAutomatically = true
|
||||||
}
|
}
|
||||||
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null)
|
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null)
|
||||||
coEvery { serverRepository.restoreSavedSession() } returns
|
coEvery { serverRepository.restoreLastSession() } returns
|
||||||
RestoredSession.ServerOnly(server)
|
RestoredSession.ServerOnly(server)
|
||||||
coEvery {
|
coEvery {
|
||||||
serverRepository.tryChangeUser(serverId, userId)
|
serverRepository.tryChangeUser(serverId, userId)
|
||||||
|
|
@ -225,7 +212,7 @@ class TestMainActivityViewModel {
|
||||||
|
|
||||||
viewModel.handleAppStart()
|
viewModel.handleAppStart()
|
||||||
|
|
||||||
coVerify(exactly = 1) { serverRepository.restoreSavedSession() }
|
coVerify(exactly = 1) { serverRepository.restoreLastSession() }
|
||||||
val slot = slot<SetupDestination>()
|
val slot = slot<SetupDestination>()
|
||||||
verify { setupNavigationManager.navigateTo(capture(slot)) }
|
verify { setupNavigationManager.navigateTo(capture(slot)) }
|
||||||
slot.captured.let { destination ->
|
slot.captured.let { destination ->
|
||||||
|
|
@ -245,11 +232,11 @@ class TestMainActivityViewModel {
|
||||||
signInAutomatically = true
|
signInAutomatically = true
|
||||||
}
|
}
|
||||||
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null)
|
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null)
|
||||||
coEvery { serverRepository.restoreSavedSession() } returns RestoredSession.None
|
coEvery { serverRepository.restoreLastSession() } returns RestoredSession.None
|
||||||
|
|
||||||
viewModel.handleAppStart()
|
viewModel.handleAppStart()
|
||||||
|
|
||||||
coVerify(exactly = 1) { serverRepository.restoreSavedSession() }
|
coVerify(exactly = 1) { serverRepository.restoreLastSession() }
|
||||||
val slot = slot<SetupDestination>()
|
val slot = slot<SetupDestination>()
|
||||||
verify { setupNavigationManager.navigateTo(capture(slot)) }
|
verify { setupNavigationManager.navigateTo(capture(slot)) }
|
||||||
slot.captured.let { destination ->
|
slot.captured.let { destination ->
|
||||||
|
|
@ -267,7 +254,7 @@ class TestMainActivityViewModel {
|
||||||
signInAutomatically = true
|
signInAutomatically = true
|
||||||
}
|
}
|
||||||
every { serverRepository.current } returns MutableStateFlow(currentUser)
|
every { serverRepository.current } returns MutableStateFlow(currentUser)
|
||||||
coEvery { serverRepository.restoreSavedSession() } returns
|
coEvery { serverRepository.restoreLastSession() } returns
|
||||||
RestoredSession.Success(currentUser)
|
RestoredSession.Success(currentUser)
|
||||||
coEvery { intentService.parseIntent(any()) } returns IntentResult.NoOp
|
coEvery { intentService.parseIntent(any()) } returns IntentResult.NoOp
|
||||||
|
|
||||||
|
|
@ -284,7 +271,7 @@ class TestMainActivityViewModel {
|
||||||
// destination as SetupDestination.AppContent
|
// destination as SetupDestination.AppContent
|
||||||
// Assert.assertEquals(currentUser, destination.current)
|
// Assert.assertEquals(currentUser, destination.current)
|
||||||
// }
|
// }
|
||||||
coVerify(exactly = 0) { serverRepository.restoreSavedSession() }
|
coVerify(exactly = 0) { serverRepository.restoreLastSession() }
|
||||||
coVerify(exactly = 0) { serverRepository.getMostRecentServer() }
|
coVerify(exactly = 0) { serverRepository.getMostRecentServer() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
package com.github.damontecres.wholphin.test
|
package com.github.damontecres.wholphin.test
|
||||||
|
|
||||||
import com.github.damontecres.wholphin.BuildConfig
|
|
||||||
import com.github.damontecres.wholphin.util.Version
|
import com.github.damontecres.wholphin.util.Version
|
||||||
import org.junit.Assert
|
import org.junit.Assert
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
@ -19,12 +18,6 @@ class VersionCompareTests {
|
||||||
val V_0_1_1_5 = Version.fromString("v0.1.1-5-gabc1234")
|
val V_0_1_1_5 = Version.fromString("v0.1.1-5-gabc1234")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun optimizedVersionRemainsCompatibleWithUpgradeAndUpdateChecks() {
|
|
||||||
val optimizedVersion = Version.fromString(requireNotNull(System.getProperty("wolfskin.optimizedVersionName")))
|
|
||||||
Assert.assertEquals(Version.fromString(BuildConfig.VERSION_NAME), optimizedVersion)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testIsAtLeast() {
|
fun testIsAtLeast() {
|
||||||
Assert.assertTrue(V_0_2_0.isAtLeast(V_0_2_0))
|
Assert.assertTrue(V_0_2_0.isAtLeast(V_0_2_0))
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,6 @@ import org.jellyfin.sdk.api.okhttp.OkHttpFactory
|
||||||
import org.jellyfin.sdk.model.ClientInfo
|
import org.jellyfin.sdk.model.ClientInfo
|
||||||
import org.jellyfin.sdk.model.DeviceInfo
|
import org.jellyfin.sdk.model.DeviceInfo
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import org.justinvisser.wolfskin.conductarr.PersistentDns
|
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
|
|
@ -92,11 +91,12 @@ object TestModule {
|
||||||
@StandardOkHttpClient
|
@StandardOkHttpClient
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun okHttpClient(dns: PersistentDns) = AppModule.okHttpClient(dns)
|
fun okHttpClient() =
|
||||||
|
OkHttpClient
|
||||||
@Provides
|
.Builder()
|
||||||
@Singleton
|
.apply {
|
||||||
fun dns(@ApplicationContext context: Context): PersistentDns = AppModule.dns(context)
|
// TODO user agent, timeouts, logging, etc
|
||||||
|
}.build()
|
||||||
|
|
||||||
@AuthOkHttpClient
|
@AuthOkHttpClient
|
||||||
@Provides
|
@Provides
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,6 @@ class PlayExternalViewModelTest {
|
||||||
every { api.subtitleApi } returns subtitleApi
|
every { api.subtitleApi } returns subtitleApi
|
||||||
every { api.playStateApi } returns playStateApi
|
every { api.playStateApi } returns playStateApi
|
||||||
every { serverRepository.currentUser } returns null
|
every { serverRepository.currentUser } returns null
|
||||||
every { serverRepository.currentServer } returns null
|
|
||||||
coEvery { userLibraryApi.getItem(movie.id) } returns successResponse(movie)
|
coEvery { userLibraryApi.getItem(movie.id) } returns successResponse(movie)
|
||||||
coEvery { streamChoiceService.chooseSource(any(), any()) } returns mediaSource
|
coEvery { streamChoiceService.chooseSource(any(), any()) } returns mediaSource
|
||||||
coEvery { streamChoiceService.chooseSubtitleStream(any(), any(), any(), any(), any(), any()) } returns
|
coEvery { streamChoiceService.chooseSubtitleStream(any(), any(), any(), any(), any(), any()) } returns
|
||||||
|
|
|
||||||
|
|
@ -1,95 +0,0 @@
|
||||||
package com.github.damontecres.wholphin.util
|
|
||||||
|
|
||||||
import android.app.Application
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import org.robolectric.RobolectricTestRunner
|
|
||||||
import org.robolectric.RuntimeEnvironment
|
|
||||||
import org.robolectric.annotation.Config
|
|
||||||
import androidx.media3.common.Player
|
|
||||||
import io.mockk.*
|
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.test.*
|
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
|
||||||
import org.jellyfin.sdk.api.client.extensions.playStateApi
|
|
||||||
import org.jellyfin.sdk.api.operations.PlayStateApi
|
|
||||||
import org.jellyfin.sdk.model.api.PlayMethod
|
|
||||||
import com.github.damontecres.wholphin.ui.successResponse
|
|
||||||
import org.junit.Assert.*
|
|
||||||
import org.junit.Test
|
|
||||||
import java.util.UUID
|
|
||||||
import org.justinvisser.wolfskin.conductarr.FileCacheStore
|
|
||||||
import org.justinvisser.wolfskin.conductarr.CacheEntry
|
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
/** Return refresh waits for the stop write rather than racing its acknowledgement. */
|
|
||||||
@RunWith(RobolectricTestRunner::class)
|
|
||||||
@Config(sdk = [34], application = Application::class)
|
|
||||||
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
|
|
||||||
class PlaybackWatchStateTest {
|
|
||||||
@Test fun revisionAppearsOnlyAfterTheCapturedUsersStopIsAcknowledged() = runTest {
|
|
||||||
val dispatcher = StandardTestDispatcher(testScheduler)
|
|
||||||
val previous = WholphinDispatchers.IO
|
|
||||||
Dispatchers.setMain(dispatcher)
|
|
||||||
WholphinDispatchers.IO = dispatcher
|
|
||||||
val api = mockk<ApiClient>()
|
|
||||||
val reports = mockk<PlayStateApi>()
|
|
||||||
every { api.playStateApi } returns reports
|
|
||||||
val player = mockk<Player> { every { currentPosition } returns 751_246L }
|
|
||||||
val scope = "server:${UUID.randomUUID()}"
|
|
||||||
val another = "server:${UUID.randomUUID()}"
|
|
||||||
val context = RuntimeEnvironment.getApplication()
|
|
||||||
val cache = FileCacheStore(File(context.filesDir, "wolfskin-library-cache"))
|
|
||||||
val saved = CacheEntry("old progress", null, 1L, 1L, group = "library")
|
|
||||||
cache.write(scope, "library:movies", saved)
|
|
||||||
cache.write(another, "library:movies", saved)
|
|
||||||
val acknowledgement = CompletableDeferred<Unit>()
|
|
||||||
coEvery { reports.reportPlaybackStopped(any()) } coAnswers {
|
|
||||||
acknowledgement.await()
|
|
||||||
successResponse(Unit)
|
|
||||||
}
|
|
||||||
val state = PlaybackItemState(UUID.randomUUID(), PlayMethod.DIRECT_PLAY)
|
|
||||||
val listener = TrackActivityPlaybackListener(api, player, onStopped = { PlaybackWatchState.stopped(RuntimeEnvironment.getApplication(), scope) }, getState = { state })
|
|
||||||
try {
|
|
||||||
listener.release()
|
|
||||||
runCurrent()
|
|
||||||
assertNull(PlaybackWatchState.revisions.value[scope])
|
|
||||||
assertNotNull(cache.read(scope, "library:movies"))
|
|
||||||
acknowledgement.complete(Unit)
|
|
||||||
advanceUntilIdle()
|
|
||||||
assertNotNull(PlaybackWatchState.revisions.value[scope])
|
|
||||||
assertNull(PlaybackWatchState.revisions.value[another])
|
|
||||||
assertNull(cache.read(scope, "library:movies"))
|
|
||||||
assertEquals(saved, cache.read(another, "library:movies"))
|
|
||||||
coVerify(exactly = 1) { reports.reportPlaybackStopped(match { it.itemId == state.itemId && it.positionTicks == 7_512_460_000L }) }
|
|
||||||
listener.release()
|
|
||||||
advanceUntilIdle()
|
|
||||||
coVerify(exactly = 1) { reports.reportPlaybackStopped(any()) }
|
|
||||||
} finally {
|
|
||||||
WholphinDispatchers.IO = previous
|
|
||||||
Dispatchers.resetMain()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun failedStopDoesNotPublishAFictitiousAcknowledgement() = runTest {
|
|
||||||
val dispatcher = StandardTestDispatcher(testScheduler)
|
|
||||||
val previous = WholphinDispatchers.IO
|
|
||||||
Dispatchers.setMain(dispatcher)
|
|
||||||
WholphinDispatchers.IO = dispatcher
|
|
||||||
val api = mockk<ApiClient>()
|
|
||||||
val reports = mockk<PlayStateApi>()
|
|
||||||
every { api.playStateApi } returns reports
|
|
||||||
coEvery { reports.reportPlaybackStopped(any()) } throws java.io.IOException("Stop rejected")
|
|
||||||
val scope = "server:${UUID.randomUUID()}"
|
|
||||||
val listener = TrackActivityPlaybackListener(api, mockk<Player> { every { currentPosition } returns 751_246L },
|
|
||||||
onStopped = { PlaybackWatchState.stopped(RuntimeEnvironment.getApplication(), scope) }, getState = { PlaybackItemState(UUID.randomUUID(), PlayMethod.DIRECT_PLAY) })
|
|
||||||
try {
|
|
||||||
listener.release()
|
|
||||||
advanceUntilIdle()
|
|
||||||
assertNull(PlaybackWatchState.revisions.value[scope])
|
|
||||||
} finally {
|
|
||||||
WholphinDispatchers.IO = previous
|
|
||||||
Dispatchers.resetMain()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-2.0-only
|
|
||||||
package org.justinvisser.wolfskin.tv
|
|
||||||
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import org.junit.Assert.assertFalse
|
|
||||||
import org.junit.Assert.assertNull
|
|
||||||
import org.justinvisser.wolfskin.conductarr.model.ReadinessStage
|
|
||||||
import org.justinvisser.wolfskin.design.ComingInStage
|
|
||||||
import org.justinvisser.wolfskin.design.ShellPerson
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Test
|
|
||||||
import org.justinvisser.wolfskin.conductarr.model.ActivityFeed
|
|
||||||
|
|
||||||
/** Recorded history separates acquisition, visibility proof, entitlement and file health. */
|
|
||||||
class ActivityTruthProjectionTest {
|
|
||||||
private val rows = Json { ignoreUnknownKeys = true }.decodeFromString<ActivityFeed>(
|
|
||||||
javaClass.getResourceAsStream("/conductarr/activity-history.json")!!.bufferedReader().use { it.readText() }).items.orEmpty().associateBy { it.id }
|
|
||||||
@Test fun recordedHistoryDoesNotClaimActiveWorkOrFailedDownloadsWithoutEvidence() {
|
|
||||||
val viewer = ShellPerson("justin", "Justin", "J", false)
|
|
||||||
val expected = mapOf(
|
|
||||||
"request-2" to "Check needed", // Steins;Gate: represented file, failed visibility proof.
|
|
||||||
"request-1" to "Check needed", // Yu Yu Hakusho: represented files, failed visibility proof.
|
|
||||||
"dispatch-732" to "Check needed", // Love is Blind: same proof failure, not failed acquisition.
|
|
||||||
"dispatch-750" to "In another library", // Spider-Man: Homecoming: held elsewhere.
|
|
||||||
"dispatch-725" to "Removed", // One Hour Photo: deleted, absent actor.
|
|
||||||
"dispatch-723" to "Removed", // A historical play is not a current holding.
|
|
||||||
"dispatch-728" to "Not out yet", // The Body: unreleased.
|
|
||||||
"dispatch-721" to "File health needs attention", // Avatar: playback fault despite a holding.
|
|
||||||
"request-8" to "Waiting for a file", // Partition: no active transfer is established.
|
|
||||||
"dispatch-755" to "Waiting for a file", // Netherlands: primary import not observed.
|
|
||||||
)
|
|
||||||
assertNull(rows.getValue("dispatch-725").comingIn().actor(viewer))
|
|
||||||
expected.forEach { (id, label) -> assertEquals(rows.getValue(id).title, label, rows.getValue(id).comingIn().stage.label) }
|
|
||||||
listOf("request-2", "request-1", "dispatch-732", "dispatch-721").forEach { id ->
|
|
||||||
val source = rows.getValue(id)
|
|
||||||
val projected = source.comingIn()
|
|
||||||
val affected = source.stages.orEmpty().first { it.state == ReadinessStage.State.FAILED }
|
|
||||||
assertEquals(affected.summary, projected.summary)
|
|
||||||
assertFalse(projected.readiness[1] == "Failed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun currentHealthSummaryKeepsEpisodeAndSourceScopeEvenWhenVisibilityIsDone() {
|
|
||||||
val row = rows.getValue("dispatch-721")
|
|
||||||
val summary = "S01E15 · primary source: unreadable; S02E08 · mirror source: unreadable."
|
|
||||||
val projected = row.copy(stages = row.stages.orEmpty().map {
|
|
||||||
when (it.name) {
|
|
||||||
ReadinessStage.Name.PLAYBACK -> it.copy(summary = summary)
|
|
||||||
ReadinessStage.Name.VISIBILITY -> it.copy(state = ReadinessStage.State.DONE)
|
|
||||||
else -> it
|
|
||||||
}
|
|
||||||
}).comingIn()
|
|
||||||
assertEquals(ComingInStage.FileHealth, projected.stage)
|
|
||||||
assertEquals(summary, projected.summary)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun absentTransferEvidenceNeverTurnsInProgressIntoDownloading() {
|
|
||||||
val row = rows.getValue("request-8")
|
|
||||||
val projected = row.copy(stages = row.stages.orEmpty().map {
|
|
||||||
if (it.name == ReadinessStage.Name.ACQUISITION) it.copy(summary = "Dispatch accepted.") else it
|
|
||||||
}).comingIn()
|
|
||||||
assertEquals(ComingInStage.Waiting, projected.stage)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun missingMembershipIsNeutralButProvenReadinessStaysReady() {
|
|
||||||
val row = rows.getValue("dispatch-725")
|
|
||||||
val absent = row.copy(stages = row.stages.orEmpty().map {
|
|
||||||
if (it.name == ReadinessStage.Name.ACQUISITION) it.copy(state = ReadinessStage.State.UNKNOWN, summary = "No current observation.") else it
|
|
||||||
})
|
|
||||||
assertEquals(ComingInStage.NotInLibrary, absent.comingIn().stage)
|
|
||||||
val ready = rows.values.first { it.stages.orEmpty().any { stage -> stage.name == ReadinessStage.Name.VISIBILITY && stage.state == ReadinessStage.State.DONE } }
|
|
||||||
assertEquals(ComingInStage.Ready, ready.comingIn().stage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -52,9 +52,8 @@ class ConductarrHomeSurfaceTest {
|
||||||
session.select("${server.id}:${user.id}", SHARED_TOKEN, fake.url)
|
session.select("${server.id}:${user.id}", SHARED_TOKEN, fake.url)
|
||||||
runBlocking { session.authenticate() }
|
runBlocking { session.authenticate() }
|
||||||
val home = mockk<HomeReadFacade>()
|
val home = mockk<HomeReadFacade>()
|
||||||
coEvery { home.readWatch(user.id) } returns HomeLibrary(resume = listOf(
|
coEvery { home.read(user.id) } returns HomeLibrary(resume = listOf(
|
||||||
HomeTitle("00000000-0000-0000-0000-000000000010", "Jellyfin held item", "movie", held = true)))
|
HomeTitle("00000000-0000-0000-0000-000000000010", "Jellyfin held item", "movie", held = true)))
|
||||||
coEvery { home.readLibrary(user.id) } returns HomeLibrary()
|
|
||||||
val cache = mockk<HomeLibraryCache>(relaxed = true)
|
val cache = mockk<HomeLibraryCache>(relaxed = true)
|
||||||
every { cache.read(any()) } returns null
|
every { cache.read(any()) } returns null
|
||||||
val model = WolfskinHomeViewModel(rule.activity, home, cache, NavigationManager(), WolfskinSeeAllLists(), session)
|
val model = WolfskinHomeViewModel(rule.activity, home, cache, NavigationManager(), WolfskinSeeAllLists(), session)
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ class ConductarrProjectionTest {
|
||||||
assertEquals(ComingInStage.Downloading, request.stage)
|
assertEquals(ComingInStage.Downloading, request.stage)
|
||||||
assertEquals("Mindel", request.requesterName)
|
assertEquals("Mindel", request.requesterName)
|
||||||
assertEquals("Shared", request.actor(ShellPerson("shared", "Shared", "S", true)))
|
assertEquals("Shared", request.actor(ShellPerson("shared", "Shared", "S", true)))
|
||||||
|
assertEquals(1, requests.openCount)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-2.0-only
|
|
||||||
package org.justinvisser.wolfskin.tv
|
|
||||||
|
|
||||||
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.ui.Modifier
|
|
||||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
|
||||||
import androidx.compose.ui.test.onRoot
|
|
||||||
import androidx.compose.ui.test.assertCountEquals
|
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
|
||||||
import androidx.compose.ui.test.onAllNodesWithText
|
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
|
||||||
import com.github.takahirom.roborazzi.captureRoboImage
|
|
||||||
import java.io.File
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.flow.last
|
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import org.junit.Before
|
|
||||||
import org.junit.Rule
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import org.justinvisser.wolfskin.conductarr.ConductarrClient
|
|
||||||
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
|
|
||||||
import org.justinvisser.wolfskin.design.*
|
|
||||||
import org.robolectric.RobolectricTestRunner
|
|
||||||
import org.robolectric.annotation.Config
|
|
||||||
import org.robolectric.annotation.GraphicsMode
|
|
||||||
|
|
||||||
/** Native frames exercise recorded activity and Mindel's feed through the shipped provider. */
|
|
||||||
@RunWith(RobolectricTestRunner::class)
|
|
||||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
|
||||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
|
||||||
class ConductarrTruthCaptureTest {
|
|
||||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
|
||||||
private val people = listOf(ShellPerson("justin", "Justin", "J", false), ShellPerson("mindel", "Mindel", "M", false))
|
|
||||||
|
|
||||||
@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
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun requestHistory() = requests("requests-history", "request-2")
|
|
||||||
@Test fun requestHealth() = requests("requests-health", "dispatch-721", expanded = true)
|
|
||||||
@Test fun requestProofDetails() = requests("requests-proof", "request-2", expanded = true)
|
|
||||||
@Test fun requestRemoved() = requests("requests-removed", "dispatch-725")
|
|
||||||
@Test fun discoverMovies() = discover("movie")
|
|
||||||
@Test fun discoverSeries() = discover("tv")
|
|
||||||
|
|
||||||
private fun requests(name: String, focused: String, expanded: Boolean = false) {
|
|
||||||
FakeConductarr().use { fake ->
|
|
||||||
fake.activityBody = resource("activity-history.json")
|
|
||||||
ConductarrClient(fake.url, JUSTIN_TOKEN, MemoryCacheStore()).use { client ->
|
|
||||||
val snapshot = runBlocking { ConductarrProvider(client).requests().last() }.let { it.copy(items = it.items.map { row -> row.copy(poster = null) }) }
|
|
||||||
val shell = ShellState(people, "justin").apply {
|
|
||||||
pickerVisible = false
|
|
||||||
enterDestination("settings")
|
|
||||||
requests.update(snapshot)
|
|
||||||
openRequests()
|
|
||||||
requests.focus(focused)
|
|
||||||
if (expanded) requests.key(ShellKey.Right)
|
|
||||||
}
|
|
||||||
rule.setContent { WolfskinTheme { ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) } }
|
|
||||||
capture(name)
|
|
||||||
rule.onAllNodesWithText("by Unknown", substring = true).assertCountEquals(0)
|
|
||||||
rule.onNodeWithText("Requests · 56 requests").assertExists()
|
|
||||||
if (expanded) rule.onNodeWithText(snapshot.items.first { it.id == focused }.summary!!).assertIsDisplayed()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun discover(type: String) {
|
|
||||||
FakeConductarr().use { fake ->
|
|
||||||
fake.feedBody = resource("mindel-feed.json")
|
|
||||||
ConductarrClient(fake.url, MINDEL_TOKEN, MemoryCacheStore()).use { client ->
|
|
||||||
val provider = ConductarrProvider(client)
|
|
||||||
val snapshot = runBlocking { provider.discover("mindel").last() }.let { original ->
|
|
||||||
fun noArt(title: DiscoverTitle) = title.copy(poster = null, backdrop = null)
|
|
||||||
original.copy(picks = original.picks.map(::noArt), seeds = original.seeds.map { it.copy(items = it.items.map(::noArt)) })
|
|
||||||
}
|
|
||||||
val shell = ShellState(people, "mindel").apply { pickerVisible = false; enterDestination("discover") }
|
|
||||||
val operator = runBlocking { (client.read(org.justinvisser.wolfskin.conductarr.TvRoutes.operator()).last() as org.justinvisser.wolfskin.conductarr.ReadResult.Value).saved.value }
|
|
||||||
val state = DiscoverState(shell, snapshot, provider.operations(requireNotNull(operator)), CoroutineScope(Dispatchers.Main.immediate))
|
|
||||||
state.filter = type
|
|
||||||
state.focus(state.firstShelf + state.lanes.indexOfFirst { it.title.startsWith("Because you watched") }, 0)
|
|
||||||
rule.setContent { WolfskinTheme { ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { DiscoverScreen(state, it) } } }
|
|
||||||
capture("discover-$type")
|
|
||||||
rule.onNodeWithText(if (type == "tv") "Because you watched Gilmore Girls" else "Because you watched Locke").assertIsDisplayed()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun capture(name: String) {
|
|
||||||
rule.mainClock.advanceTimeBy(1000)
|
|
||||||
rule.waitForIdle()
|
|
||||||
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "truth").apply { mkdirs() }
|
|
||||||
rule.onRoot().captureRoboImage(File(directory, "$name.png").absolutePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun resource(name: String) = javaClass.getResourceAsStream("/conductarr/$name")!!.bufferedReader().use { it.readText() }
|
|
||||||
}
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-2.0-only
|
|
||||||
package org.justinvisser.wolfskin.tv
|
|
||||||
|
|
||||||
import kotlinx.coroutines.flow.last
|
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import kotlinx.serialization.json.*
|
|
||||||
import org.junit.Assert.*
|
|
||||||
import org.junit.Test
|
|
||||||
import org.justinvisser.wolfskin.conductarr.ConductarrClient
|
|
||||||
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
|
|
||||||
|
|
||||||
/** Equal captions are not equal seed identities; repeated contributions union their evidence. */
|
|
||||||
class DiscoverSeedProjectionTest {
|
|
||||||
@Test fun seedIdentitySurvivesTitleCollisionsAndWatchEvidenceAcrossCandidates() = runBlocking {
|
|
||||||
FakeConductarr().use { fake ->
|
|
||||||
val feed = fake.wire.getValue("feed").jsonObject
|
|
||||||
val candidate = feed.getValue("movies").jsonArray.first().jsonObject
|
|
||||||
val first = JsonObject(candidate + ("contributions" to Json.parseToJsonElement("""[
|
|
||||||
{"media_type":"movie","tmdb_id":1,"title":"Same title","watched":true,"added":false},
|
|
||||||
{"media_type":"tv","tmdb_id":1,"title":"Same title","watched":false,"added":true},
|
|
||||||
{"media_type":"movie","tmdb_id":2,"watched":true,"added":false}
|
|
||||||
]""")))
|
|
||||||
val second = JsonObject(candidate + mapOf("tmdb_id" to JsonPrimitive(43), "contributions" to Json.parseToJsonElement("""[
|
|
||||||
{"media_type":"movie","tmdb_id":1,"title":"Renamed seed","watched":false,"added":true}
|
|
||||||
]""")))
|
|
||||||
fake.feedBody = JsonObject(feed + ("movies" to JsonArray(listOf(first, second)))).toString()
|
|
||||||
ConductarrClient(fake.url, MINDEL_TOKEN, MemoryCacheStore()).use { client ->
|
|
||||||
val seeds = ConductarrProvider(client).discover("mindel").last().seeds
|
|
||||||
assertEquals(listOf("movie:1", "tv:1"), seeds.map { it.key })
|
|
||||||
assertEquals(listOf(42L, 43L), seeds.first().items.map { it.tmdb })
|
|
||||||
assertTrue(seeds.first().watched && seeds.first().added)
|
|
||||||
assertFalse(seeds.last().watched)
|
|
||||||
assertTrue(seeds.last().added)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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) } }
|
|
||||||
}
|
|
||||||
|
|
@ -29,8 +29,6 @@ internal class FakeConductarr : Closeable {
|
||||||
@Volatile var localArt = false
|
@Volatile var localArt = false
|
||||||
@Volatile var beforeResponse: ((String) -> Unit)? = null
|
@Volatile var beforeResponse: ((String) -> Unit)? = null
|
||||||
@Volatile var audiencesBody: String? = null
|
@Volatile var audiencesBody: String? = null
|
||||||
@Volatile var feedBody: String? = null
|
|
||||||
@Volatile var activityBody: String? = null
|
|
||||||
private val imageBytes = java.util.Base64.getDecoder().decode(
|
private val imageBytes = java.util.Base64.getDecoder().decode(
|
||||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jYioAAAAASUVORK5CYII=")
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jYioAAAAASUVORK5CYII=")
|
||||||
|
|
||||||
|
|
@ -71,8 +69,6 @@ internal class FakeConductarr : Closeable {
|
||||||
if (key == "ratings" && ratingsStatus != 200) return response("", ratingsStatus)
|
if (key == "ratings" && ratingsStatus != 200) return response("", ratingsStatus)
|
||||||
if (key == "audiences" && audiencesStatus != 200) return response("", audiencesStatus)
|
if (key == "audiences" && audiencesStatus != 200) return response("", audiencesStatus)
|
||||||
if (key == "audiences") audiencesBody?.let { return response(it) }
|
if (key == "audiences") audiencesBody?.let { return response(it) }
|
||||||
if (key == "feed") feedBody?.let { return response(it) }
|
|
||||||
if (key == "activity") activityBody?.let { return response(it) }
|
|
||||||
if (notModified && request.headers["If-None-Match"] != null) return response("", 304)
|
if (notModified && request.headers["If-None-Match"] != null) return response("", 304)
|
||||||
if (key == "requests" && !existingRequests) return response("{\"requests\":[]}")
|
if (key == "requests" && !existingRequests) return response("{\"requests\":[]}")
|
||||||
var body = wire.getValue(key).jsonObject
|
var body = wire.getValue(key).jsonObject
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,7 @@ class FirstSignInNavigationTest {
|
||||||
coEvery { views.getUserViews(userId = user.id) } returns successQueryResult(listOf(movies, shows))
|
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))) {
|
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,
|
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(
|
sortBy = listOf(ItemSortBy.SORT_NAME), startIndex = 0, limit = 200) } returns successQueryResult(
|
||||||
List(count) { BaseItemDto(id = UUID.randomUUID(), name = "${library.name} title $it", type = kind) })
|
List(count) { BaseItemDto(id = UUID.randomUUID(), name = "${library.name} title $it", type = kind) })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,13 +43,11 @@ class HomeRecoveryTest {
|
||||||
val started = CompletableDeferred<Unit>()
|
val started = CompletableDeferred<Unit>()
|
||||||
val response = CompletableDeferred<HomeLibrary>()
|
val response = CompletableDeferred<HomeLibrary>()
|
||||||
val facade = mockk<HomeReadFacade>()
|
val facade = mockk<HomeReadFacade>()
|
||||||
coEvery { facade.readWatch(personA.id) } coAnswers { started.complete(Unit); response.await() }
|
coEvery { facade.read(personA.id) } coAnswers { started.complete(Unit); response.await() }
|
||||||
coEvery { facade.readLibrary(any()) } returns HomeLibrary()
|
|
||||||
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
|
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
|
||||||
val refresh = async(Dispatchers.IO) { model.loadLibrary(server, personA, viewerA, retry = true) }
|
val refresh = async(Dispatchers.IO) { model.loadLibrary(server, personA, viewerA, retry = true) }
|
||||||
started.await()
|
started.await()
|
||||||
assertEquals(film.id, model.snapshot.rows.first().cards.first().id)
|
assertEquals(film.id, model.snapshot.rows.first().cards.first().id)
|
||||||
assertFalse(model.snapshot.rows.first().watchReady)
|
|
||||||
response.completeExceptionally(IOException("fixture offline"))
|
response.completeExceptionally(IOException("fixture offline"))
|
||||||
refresh.await()
|
refresh.await()
|
||||||
assertFalse(model.snapshot.jellyfinAvailable)
|
assertFalse(model.snapshot.jellyfinAvailable)
|
||||||
|
|
@ -64,9 +62,8 @@ class HomeRecoveryTest {
|
||||||
cache.write("${server.id}:${personA.id}", library, 1000L)
|
cache.write("${server.id}:${personA.id}", library, 1000L)
|
||||||
val startedA = CompletableDeferred<Unit>()
|
val startedA = CompletableDeferred<Unit>()
|
||||||
val responseA = CompletableDeferred<HomeLibrary>()
|
val responseA = CompletableDeferred<HomeLibrary>()
|
||||||
coEvery { facade.readWatch(personA.id) } coAnswers { startedA.complete(Unit); responseA.await() }
|
coEvery { facade.read(personA.id) } coAnswers { startedA.complete(Unit); responseA.await() }
|
||||||
coEvery { facade.readWatch(personB.id) } throws IOException("fixture offline")
|
coEvery { facade.read(personB.id) } throws IOException("fixture offline")
|
||||||
coEvery { facade.readLibrary(any()) } returns HomeLibrary()
|
|
||||||
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
|
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
|
||||||
val requestA = async(Dispatchers.IO) { model.loadLibrary(server, personA, viewerA) }
|
val requestA = async(Dispatchers.IO) { model.loadLibrary(server, personA, viewerA) }
|
||||||
startedA.await()
|
startedA.await()
|
||||||
|
|
@ -83,8 +80,7 @@ class HomeRecoveryTest {
|
||||||
|
|
||||||
@Test fun readableJellyfinDoesNotBecomeAnOutageWhenLocalCacheCannotBeWritten() = runBlocking {
|
@Test fun readableJellyfinDoesNotBecomeAnOutageWhenLocalCacheCannotBeWritten() = runBlocking {
|
||||||
val facade = mockk<HomeReadFacade>()
|
val facade = mockk<HomeReadFacade>()
|
||||||
coEvery { facade.readWatch(personA.id) } returns library
|
coEvery { facade.read(personA.id) } returns library
|
||||||
coEvery { facade.readLibrary(any()) } returns HomeLibrary()
|
|
||||||
val cache = mockk<HomeLibraryCache>()
|
val cache = mockk<HomeLibraryCache>()
|
||||||
every { cache.read(any()) } returns null
|
every { cache.read(any()) } returns null
|
||||||
every { cache.write(any(), any(), any()) } throws IOException("fixture disk full")
|
every { cache.write(any(), any(), any()) } throws IOException("fixture disk full")
|
||||||
|
|
@ -97,8 +93,7 @@ class HomeRecoveryTest {
|
||||||
|
|
||||||
@Test fun renamedHouseholdProfileKeepsItsOwnJellyfinLibraryWithoutAConductarrIdentity() = runBlocking {
|
@Test fun renamedHouseholdProfileKeepsItsOwnJellyfinLibraryWithoutAConductarrIdentity() = runBlocking {
|
||||||
val facade = mockk<HomeReadFacade>()
|
val facade = mockk<HomeReadFacade>()
|
||||||
coEvery { facade.readWatch(personA.id) } returns library
|
coEvery { facade.read(personA.id) } returns library
|
||||||
coEvery { facade.readLibrary(any()) } returns HomeLibrary()
|
|
||||||
val cache = mockk<HomeLibraryCache>(relaxed = true)
|
val cache = mockk<HomeLibraryCache>(relaxed = true)
|
||||||
every { cache.read(any()) } returns null
|
every { cache.read(any()) } returns null
|
||||||
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
|
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ class LibraryReadFacadeTest {
|
||||||
val offsets = mutableListOf<Int>()
|
val offsets = mutableListOf<Int>()
|
||||||
coEvery { items.getItems(userId = user, parentId = library.id, recursive = true,
|
coEvery { items.getItems(userId = user, parentId = library.id, recursive = true,
|
||||||
includeItemTypes = listOf(BaseItemKind.MOVIE),
|
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 {
|
sortBy = listOf(ItemSortBy.SORT_NAME), startIndex = capture(offsets), limit = 200) } coAnswers {
|
||||||
if (offsets.last() == 0) successQueryResult(firstChunk, 202) else {
|
if (offsets.last() == 0) successQueryResult(firstChunk, 202) else {
|
||||||
assertEquals(200, offsets.last())
|
assertEquals(200, offsets.last())
|
||||||
|
|
|
||||||
|
|
@ -48,21 +48,6 @@ class LibrarySnapshotStoreTest {
|
||||||
assertFalse(reads.last().unreachable)
|
assertFalse(reads.last().unreachable)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun acknowledgedPlaybackInvalidatesOnlyOlderWatchSnapshotsBeforeTheTtl() = runBlocking {
|
|
||||||
var now = 1000L
|
|
||||||
val store = LibrarySnapshotStore(MemoryCacheStore()) { now }
|
|
||||||
val old = listOf(LibraryTitle("film", "Film", "movie", progress = .1f))
|
|
||||||
store.read("server:justin", "movies") { flowOf(old) }.toList()
|
|
||||||
store.read("server:mindel", "movies") { flowOf(old) }.toList()
|
|
||||||
now = 1002L
|
|
||||||
val updated = listOf(old.single().copy(progress = .6f))
|
|
||||||
val reads = store.read("server:justin", "movies", invalidatedAtMillis = 1001L) { flowOf(updated) }.toList()
|
|
||||||
assertEquals(.1f, reads.first().items.single().progress)
|
|
||||||
assertEquals(.6f, reads.last().items.single().progress)
|
|
||||||
assertEquals(updated, store.read("server:justin", "movies", invalidatedAtMillis = 1001L) { error("Already revalidated") }.toList().single().items)
|
|
||||||
assertEquals(old, store.read("server:mindel", "movies") { error("Other viewer stays cached") }.toList().single().items)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun failedLaterChunkKeepsTheCompleteSavedCopyOrTheColdPartialWithoutSavingIt() = runBlocking {
|
@Test fun failedLaterChunkKeepsTheCompleteSavedCopyOrTheColdPartialWithoutSavingIt() = runBlocking {
|
||||||
val partial = listOf(LibraryTitle("partial", "First film", "movie"))
|
val partial = listOf(LibraryTitle("partial", "First film", "movie"))
|
||||||
val complete = partial + LibraryTitle("last", "Last film", "movie")
|
val complete = partial + LibraryTitle("last", "Last film", "movie")
|
||||||
|
|
|
||||||
|
|
@ -1,159 +0,0 @@
|
||||||
package org.justinvisser.wolfskin.tv
|
|
||||||
|
|
||||||
import android.app.Application
|
|
||||||
import android.os.Looper
|
|
||||||
import androidx.activity.ComponentActivity
|
|
||||||
import androidx.compose.runtime.*
|
|
||||||
import androidx.compose.ui.input.key.Key
|
|
||||||
import androidx.compose.ui.test.*
|
|
||||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import com.github.damontecres.wholphin.data.CurrentUser
|
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
|
||||||
import com.github.damontecres.wholphin.data.model.*
|
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
|
||||||
import com.github.damontecres.wholphin.services.SetupDestination
|
|
||||||
import com.github.damontecres.wholphin.services.SetupNavigationManager
|
|
||||||
import io.mockk.*
|
|
||||||
import java.util.UUID
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.cancel
|
|
||||||
import org.junit.After
|
|
||||||
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.conductarr.MemoryCacheStore
|
|
||||||
import org.justinvisser.wolfskin.design.*
|
|
||||||
import org.justinvisser.wolfskin.tv.read.LibraryReadFacade
|
|
||||||
import org.robolectric.RobolectricTestRunner
|
|
||||||
import org.robolectric.Shadows
|
|
||||||
import org.robolectric.annotation.Config
|
|
||||||
|
|
||||||
/** A selected identity gets a new app ViewModel without opening a second launch picker. */
|
|
||||||
@RunWith(RobolectricTestRunner::class)
|
|
||||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
|
||||||
class PersonPickerSessionTest {
|
|
||||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
|
||||||
private lateinit var previousUrl: String
|
|
||||||
private var previousFixtures = false
|
|
||||||
private var previousScreens = true
|
|
||||||
private val models = mutableListOf<WolfskinShellViewModel>()
|
|
||||||
private lateinit var session: WolfskinSession
|
|
||||||
|
|
||||||
@Before fun capturePreferences() {
|
|
||||||
ConductarrPreference.initialize(rule.activity)
|
|
||||||
WolfskinScreenPreference.initialize(rule.activity)
|
|
||||||
previousUrl = ConductarrPreference.baseUrl
|
|
||||||
previousFixtures = ConductarrPreference.fixtures
|
|
||||||
previousScreens = WolfskinScreenPreference.enabled
|
|
||||||
ConductarrPreference.updateFixtures(false)
|
|
||||||
WolfskinScreenPreference.enabled = true
|
|
||||||
}
|
|
||||||
|
|
||||||
@After fun restorePreferences() {
|
|
||||||
models.forEach { it.viewModelScope.cancel() }
|
|
||||||
ConductarrPreference.updateBaseUrl(previousUrl)
|
|
||||||
ConductarrPreference.updateFixtures(previousFixtures)
|
|
||||||
WolfskinScreenPreference.enabled = previousScreens
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun select(initial: Int, selected: Int, fromAvatar: Boolean = false, fast: Boolean = false, moves: List<Key> = emptyList()) {
|
|
||||||
FakeConductarr().use { fake ->
|
|
||||||
ConductarrPreference.updateBaseUrl(fake.url)
|
|
||||||
ConductarrSession(MemoryCacheStore()).use { conductarr ->
|
|
||||||
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", 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)
|
|
||||||
}
|
|
||||||
var current by mutableStateOf(CurrentUser(server, users[initial]))
|
|
||||||
val repository = mockk<ServerRepository>(relaxed = true)
|
|
||||||
coEvery { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, users)
|
|
||||||
coEvery { repository.changeUser(server, any()) } coAnswers { CurrentUser(server, secondArg()) }
|
|
||||||
val setup = mockk<SetupNavigationManager>(relaxed = true)
|
|
||||||
every { setup.navigateTo(any()) } answers { current = (firstArg() as SetupDestination.AppContent).current }
|
|
||||||
val libraries = mockk<LibraryReadFacade>(relaxed = true)
|
|
||||||
coEvery { libraries.readDestinations(any()) } returns emptyMap()
|
|
||||||
coEvery { libraries.hasAnimeTitles(any()) } returns false
|
|
||||||
session = WolfskinSession()
|
|
||||||
try {
|
|
||||||
rule.setContent {
|
|
||||||
key(current.user.id) {
|
|
||||||
val navigation = remember { NavigationManager() }
|
|
||||||
val model = remember {
|
|
||||||
WolfskinShellViewModel(rule.activity, session, repository, navigation, setup, mockk(relaxed = true),
|
|
||||||
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),
|
|
||||||
viewModel = model, homeContent = { _, _, user, modifier ->
|
|
||||||
androidx.tv.material3.Text("Home for ${user.name}", modifier)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
awaitLoaded(users[initial])
|
|
||||||
if (fromAvatar) {
|
|
||||||
press(listOf(Key.DirectionCenter))
|
|
||||||
press(listOf(Key.DirectionUp, Key.DirectionLeft, Key.DirectionCenter))
|
|
||||||
}
|
|
||||||
val actions = mutableListOf<ShellAction.Person>()
|
|
||||||
rule.runOnIdle {
|
|
||||||
val dispatch = session.onAction
|
|
||||||
session.onAction = { action ->
|
|
||||||
if (action is ShellAction.Person) actions.add(action)
|
|
||||||
dispatch(action)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (fast) press(moves + Key.DirectionCenter) else {
|
|
||||||
press(moves)
|
|
||||||
press(listOf(Key.DirectionCenter))
|
|
||||||
}
|
|
||||||
awaitLoaded(users[selected])
|
|
||||||
rule.runOnIdle {
|
|
||||||
assertFalse("The replacement app must not reopen the picker after one OK", session.state!!.pickerVisible)
|
|
||||||
assertEquals("home", session.state!!.route)
|
|
||||||
assertEquals(users[selected].id.toString(), session.state!!.person.jellyfinUserId)
|
|
||||||
assertEquals(listOf(users[selected].id.toString()), actions.map { it.person.jellyfinUserId })
|
|
||||||
assertEquals(if (initial == selected) 1 else 2, models.size)
|
|
||||||
}
|
|
||||||
rule.onNodeWithText("Home for ${users[selected].name}").assertIsDisplayed()
|
|
||||||
} finally { models.forEach { it.viewModelScope.cancel() } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 &&
|
|
||||||
models.last().conductarr.identity.value?.person?.endsWith(":${user.id}") == true &&
|
|
||||||
session.state?.people?.size == 3 &&
|
|
||||||
models.last().viewModelScope.coroutineContext[Job]!!.children.none { it.isActive }
|
|
||||||
}
|
|
||||||
} catch (timeout: ComposeTimeoutException) {
|
|
||||||
throw AssertionError("Expected ${user.name}; models=${models.size}, signedIn=${models.last().signedInPerson}, " +
|
|
||||||
"identity=${models.last().conductarr.identity.value?.person}, picker=${session.state?.pickerVisible}, " +
|
|
||||||
"person=${session.state?.person}, people=${session.state?.people?.size}", timeout)
|
|
||||||
}
|
|
||||||
rule.waitForIdle()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun press(keys: List<Key>) {
|
|
||||||
rule.onNodeWithTag("wolfskin-shell").performKeyInput { keys.forEach { pressKey(it) } }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun launchRightThenOneOkKeepsPickerClosed() = select(0, 1, moves = listOf(Key.DirectionRight))
|
|
||||||
@Test fun launchLeftThenOneOkKeepsPickerClosed() = select(1, 0, moves = listOf(Key.DirectionLeft))
|
|
||||||
@Test fun avatarRightThenOneOkKeepsPickerClosed() = select(0, 1, fromAvatar = true, moves = listOf(Key.DirectionRight))
|
|
||||||
@Test fun avatarLeftThenOneOkKeepsPickerClosed() = select(1, 0, fromAvatar = true, moves = listOf(Key.DirectionLeft))
|
|
||||||
@Test fun initialPersonNeedsOnlyOneOk() = select(0, 0)
|
|
||||||
@Test fun gearAndBackThenOneOkKeepsPickerClosed() = select(0, 2, moves = listOf(Key.DirectionRight, Key.DirectionRight, Key.DirectionDown, Key.DirectionUp))
|
|
||||||
@Test fun fastLeftLeftOkKeepsPickerClosed() = select(2, 0, fast = true, moves = listOf(Key.DirectionLeft, Key.DirectionLeft))
|
|
||||||
}
|
|
||||||
|
|
@ -214,9 +214,8 @@ class PersonSwitchThreadingTest {
|
||||||
every { repository.currentUser } returns user
|
every { repository.currentUser } returns user
|
||||||
every { repository.currentServer } returns server
|
every { repository.currentServer } returns server
|
||||||
val home = mockk<org.justinvisser.wolfskin.tv.read.HomeReadFacade>()
|
val home = mockk<org.justinvisser.wolfskin.tv.read.HomeReadFacade>()
|
||||||
coEvery { home.readWatch(user.id) } returns org.justinvisser.wolfskin.design.HomeLibrary(resume = listOf(
|
coEvery { home.read(user.id) } returns org.justinvisser.wolfskin.design.HomeLibrary(resume = listOf(
|
||||||
org.justinvisser.wolfskin.design.HomeTitle("justin-film", "Justin film", "movie", held = true)))
|
org.justinvisser.wolfskin.design.HomeTitle("justin-film", "Justin film", "movie", held = true)))
|
||||||
coEvery { home.readLibrary(user.id) } returns org.justinvisser.wolfskin.design.HomeLibrary()
|
|
||||||
val cache = mockk<org.justinvisser.wolfskin.tv.read.HomeLibraryCache>(relaxed = true)
|
val cache = mockk<org.justinvisser.wolfskin.tv.read.HomeLibraryCache>(relaxed = true)
|
||||||
every { cache.read(any()) } returns null
|
every { cache.read(any()) } returns null
|
||||||
val homeModel = WolfskinHomeViewModel(context, home, cache, mockk(relaxed = true), WolfskinSeeAllLists(), conductarr)
|
val homeModel = WolfskinHomeViewModel(context, home, cache, mockk(relaxed = true), WolfskinSeeAllLists(), conductarr)
|
||||||
|
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
package org.justinvisser.wolfskin.tv
|
|
||||||
|
|
||||||
import org.jellyfin.sdk.model.api.UserItemDataDto
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
/** Checkpoints use the SDK's required identity and watch fields. */
|
|
||||||
internal fun playbackUserData(itemId: UUID, ticks: Long, played: Boolean = false, playCount: Int = 0): UserItemDataDto =
|
|
||||||
UserItemDataDto(playbackPositionTicks = ticks, playCount = playCount, isFavorite = false,
|
|
||||||
lastPlayedDate = null, played = played, key = "", itemId = itemId)
|
|
||||||
|
|
@ -58,7 +58,7 @@ class PlayerHandoffTest {
|
||||||
@Test fun failedPlaybackReadLeavesSavedHomeFocusedAndDrawsTheExactOfflineToast() {
|
@Test fun failedPlaybackReadLeavesSavedHomeFocusedAndDrawsTheExactOfflineToast() {
|
||||||
val snapshot = HomeFixture(rule.activity).snapshot("mindel", "offline")
|
val snapshot = HomeFixture(rule.activity).snapshot("mindel", "offline")
|
||||||
val title = snapshot.rows.first().cards.first()
|
val title = snapshot.rows.first().cards.first()
|
||||||
coEvery { facade.preparePlayback(user.id, title) } throws IOException("fixture offline")
|
coEvery { facade.playableId(user.id, title) } throws IOException("fixture offline")
|
||||||
rule.setContent { WolfskinTheme {
|
rule.setContent { WolfskinTheme {
|
||||||
ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { modifier ->
|
ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { modifier ->
|
||||||
HomeScreen(shell, snapshot, modifier) { model.act(it, shell, server, user) }
|
HomeScreen(shell, snapshot, modifier) { model.act(it, shell, server, user) }
|
||||||
|
|
@ -80,16 +80,14 @@ class PlayerHandoffTest {
|
||||||
@Test fun successfulPlaybackUsesTheRealFullscreenDestinationAndBackKeepsTheResumeCard() {
|
@Test fun successfulPlaybackUsesTheRealFullscreenDestinationAndBackKeepsTheResumeCard() {
|
||||||
val title = HomeFixture(rule.activity).snapshot("mindel").rows.first().cards.first()
|
val title = HomeFixture(rule.activity).snapshot("mindel").rows.first().cards.first()
|
||||||
val id = UUID.fromString("00000000-0000-0000-0000-000000000003")
|
val id = UUID.fromString("00000000-0000-0000-0000-000000000003")
|
||||||
coEvery { facade.preparePlayback(user.id, title) } returns org.jellyfin.sdk.model.api.BaseItemDto(
|
coEvery { facade.playableId(user.id, title) } returns id
|
||||||
id = id, type = org.jellyfin.sdk.model.api.BaseItemKind.EPISODE,
|
|
||||||
userData = playbackUserData(id, 11_362_740_000L, playCount = 2))
|
|
||||||
val view = shell.view
|
val view = shell.view
|
||||||
view.cards["hero"] = 0
|
view.cards["hero"] = 0
|
||||||
rule.runOnIdle { model.act(HomeAction.Play(title), shell, server, user) }
|
rule.runOnIdle { model.act(HomeAction.Play(title, resume = true), shell, server, user) }
|
||||||
awaitModel { navigation.backStack.last() is Destination.Playback }
|
awaitModel { navigation.backStack.last() is Destination.Playback }
|
||||||
val destination = navigation.backStack.last() as Destination.Playback
|
val destination = navigation.backStack.last() as Destination.Playback
|
||||||
assertEquals(id, destination.itemId)
|
assertEquals(id, destination.itemId)
|
||||||
assertEquals(1_136_274L, destination.positionMs)
|
assertEquals((title.resumeSeconds * 1000).toLong(), destination.positionMs)
|
||||||
assertTrue(destination.fullScreen)
|
assertTrue(destination.fullScreen)
|
||||||
navigation.goBack()
|
navigation.goBack()
|
||||||
assertTrue(navigation.backStack.single() is Destination.Home)
|
assertTrue(navigation.backStack.single() is Destination.Home)
|
||||||
|
|
@ -101,11 +99,11 @@ class PlayerHandoffTest {
|
||||||
@Test fun anotherPersonsSessionCannotResolveOrStartPlayback() {
|
@Test fun anotherPersonsSessionCannotResolveOrStartPlayback() {
|
||||||
val title = HomeFixture(rule.activity).snapshot("mindel").rows.first().cards.first()
|
val title = HomeFixture(rule.activity).snapshot("mindel").rows.first().cards.first()
|
||||||
val otherUser = user.copy(name = "Justin")
|
val otherUser = user.copy(name = "Justin")
|
||||||
rule.runOnIdle { model.act(HomeAction.Play(title), shell, server, otherUser) }
|
rule.runOnIdle { model.act(HomeAction.Play(title, resume = true), shell, server, otherUser) }
|
||||||
awaitModel { model.toast != null }
|
awaitModel { model.toast != null }
|
||||||
assertEquals(RecoveryMessage.PlaybackUnavailable, model.toast)
|
assertEquals(RecoveryMessage.PlaybackUnavailable, model.toast)
|
||||||
assertTrue(navigation.backStack.single() is Destination.Home)
|
assertTrue(navigation.backStack.single() is Destination.Home)
|
||||||
coVerify(exactly = 0) { facade.preparePlayback(any(), any()) }
|
coVerify(exactly = 0) { facade.playableId(any(), any()) }
|
||||||
}
|
}
|
||||||
private fun awaitModel(condition: () -> Boolean) {
|
private fun awaitModel(condition: () -> Boolean) {
|
||||||
rule.waitUntil(5000) {
|
rule.waitUntil(5000) {
|
||||||
|
|
|
||||||
|
|
@ -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))
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -11,9 +11,6 @@ import androidx.compose.ui.test.pressKey
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
import androidx.compose.ui.test.performMouseInput
|
import androidx.compose.ui.test.performMouseInput
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
|
||||||
import androidx.compose.ui.test.hasClickAction
|
|
||||||
import androidx.compose.ui.test.hasText
|
|
||||||
import androidx.compose.ui.test.performSemanticsAction
|
import androidx.compose.ui.test.performSemanticsAction
|
||||||
import androidx.compose.ui.semantics.SemanticsActions
|
import androidx.compose.ui.semantics.SemanticsActions
|
||||||
import androidx.compose.ui.text.TextLayoutResult
|
import androidx.compose.ui.text.TextLayoutResult
|
||||||
|
|
@ -65,27 +62,6 @@ class TitleKeysTest {
|
||||||
} }
|
} }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun downFocusesThePlayableLaterSeasonAndScrollsItIntoViewWithoutLosingBrowsingMemory() {
|
|
||||||
render("Andor") { page ->
|
|
||||||
page.copy(item = page.item.copy(seasons = 30), seasonNumbers = (1..30).toList(), selectedSeason = 29,
|
|
||||||
playTarget = page.playTarget!!.copy(season = 29))
|
|
||||||
}
|
|
||||||
press(Key.DirectionDown)
|
|
||||||
assertEquals("seasons", state.row)
|
|
||||||
assertEquals(28, state.column)
|
|
||||||
rule.onNode(hasText("Season 29") and hasClickAction()).assertIsDisplayed()
|
|
||||||
press(Key.DirectionLeft)
|
|
||||||
assertEquals(27, state.column)
|
|
||||||
rule.onNode(hasText("Season 28") and hasClickAction()).assertIsDisplayed()
|
|
||||||
press(Key.DirectionUp)
|
|
||||||
press(Key.DirectionDown)
|
|
||||||
assertEquals(27, state.column)
|
|
||||||
press(Key.DirectionCenter)
|
|
||||||
assertEquals(28, state.season)
|
|
||||||
assertEquals(28, state.requestedSeason)
|
|
||||||
assertEquals(TitleAction.Season(28), effects.last())
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun press(key: Key) { rule.onRoot().performKeyInput { pressKey(key) } }
|
private fun press(key: Key) { rule.onRoot().performKeyInput { pressKey(key) } }
|
||||||
private fun replay(end: String): Int {
|
private fun replay(end: String): Int {
|
||||||
val path = paths.single { it.getString("end") == end }
|
val path = paths.single { it.getString("end") == end }
|
||||||
|
|
@ -336,6 +312,11 @@ class TitleKeysTest {
|
||||||
assertEquals(553f, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot.top, 1f)
|
assertEquals(553f, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot.top, 1f)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
@Test fun episodeProgressReclaimsItsNegativeMarginBeforeTheActionRow() {
|
||||||
|
render("Andor")
|
||||||
|
assertEquals(603f, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot.top, 1f)
|
||||||
|
}
|
||||||
|
|
||||||
@Test fun everyLongTrackChoiceAndDoneRemainInsideTheVisibleSheet() {
|
@Test fun everyLongTrackChoiceAndDoneRemainInsideTheVisibleSheet() {
|
||||||
render(transform = { data ->
|
render(transform = { data ->
|
||||||
|
|
|
||||||
|
|
@ -1,136 +0,0 @@
|
||||||
package org.justinvisser.wolfskin.tv
|
|
||||||
|
|
||||||
import android.app.Application
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
|
||||||
import io.mockk.coEvery
|
|
||||||
import io.mockk.coVerify
|
|
||||||
import io.mockk.mockk
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.cancel
|
|
||||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
|
||||||
import kotlinx.coroutines.test.advanceUntilIdle
|
|
||||||
import kotlinx.coroutines.test.resetMain
|
|
||||||
import kotlinx.coroutines.test.runTest
|
|
||||||
import kotlinx.coroutines.test.setMain
|
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
|
||||||
import org.junit.Assert.*
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
|
|
||||||
import org.justinvisser.wolfskin.design.*
|
|
||||||
import org.justinvisser.wolfskin.tv.read.TitleReadFacade
|
|
||||||
import org.robolectric.RobolectricTestRunner
|
|
||||||
import org.robolectric.RuntimeEnvironment
|
|
||||||
import org.robolectric.annotation.Config
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
/** Production title actions preserve parents and resolve live playback instead of cached offsets. */
|
|
||||||
@RunWith(RobolectricTestRunner::class)
|
|
||||||
@Config(sdk = [34], application = Application::class)
|
|
||||||
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
|
|
||||||
class TitlePlaybackNavigationTest {
|
|
||||||
private val seriesId = UUID.fromString("00000000-0000-0000-0000-000000000001")
|
|
||||||
private val firstId = UUID.fromString("00000000-0000-0000-0000-000000000002")
|
|
||||||
private val secondId = UUID.fromString("00000000-0000-0000-0000-000000000003")
|
|
||||||
private val person = ShellPerson("justin", "Justin", "J", false)
|
|
||||||
private val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null)
|
|
||||||
private val series = TitleItem(seriesId.toString(), "Series", "tv", seasons = 5, held = true)
|
|
||||||
private val first = TitleItem(firstId.toString(), "First episode", "episode", held = true, season = 3, episode = 1, resumeSeconds = 12)
|
|
||||||
private val second = first.copy(id = secondId.toString(), name = "Second episode", episode = 2)
|
|
||||||
|
|
||||||
@Test fun repeatedEpisodeChoicesReplaceOnlyEpisodesAndBackPreservesTheirSeriesParent() = runTest {
|
|
||||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
|
||||||
val navigation = NavigationManager()
|
|
||||||
val facade = mockk<TitleReadFacade>()
|
|
||||||
coEvery { facade.readSaved(any(), any()) } returns null
|
|
||||||
coEvery { facade.read(any(), any(), any()) } coAnswers {
|
|
||||||
val id = firstArg<UUID>()
|
|
||||||
TitlePageData(series, playTarget = first, watchTarget = if (id == seriesId) series else first.copy(id = id.toString()),
|
|
||||||
episodes = listOf(first, second), related = listOf(series), cast = listOf(TitlePerson("actor", "Actor")))
|
|
||||||
}
|
|
||||||
val session = ConductarrSession(MemoryCacheStore())
|
|
||||||
val model = WolfskinTitleViewModel(RuntimeEnvironment.getApplication(), facade, mockk(), navigation, session, mockk<ServerRepository>(relaxed = true))
|
|
||||||
fun open(id: UUID) = model.load(titleRoute(id.toString()), person, preferences, fixtures = false, force = true)
|
|
||||||
try {
|
|
||||||
navigation.navigateTo(Destination.Wolfskin(titleRoute(seriesId.toString())))
|
|
||||||
open(seriesId)
|
|
||||||
advanceUntilIdle()
|
|
||||||
model.title!!.click("episodes", 0)
|
|
||||||
assertEquals(3, navigation.backStack.size)
|
|
||||||
assertEquals(firstId, (navigation.backStack.last() as Destination.MediaItem).itemId)
|
|
||||||
open(firstId)
|
|
||||||
advanceUntilIdle()
|
|
||||||
for (id in listOf(secondId, firstId, secondId)) {
|
|
||||||
model.title!!.click("episodes", if (id == firstId) 0 else 1)
|
|
||||||
assertEquals(3, navigation.backStack.size)
|
|
||||||
assertEquals(id, (navigation.backStack.last() as Destination.MediaItem).itemId)
|
|
||||||
open(id)
|
|
||||||
advanceUntilIdle()
|
|
||||||
}
|
|
||||||
model.title!!.click("related", 0)
|
|
||||||
assertEquals(4, navigation.backStack.size)
|
|
||||||
navigation.goBack()
|
|
||||||
model.title!!.click("cast", 0)
|
|
||||||
assertEquals(4, navigation.backStack.size)
|
|
||||||
navigation.goBack()
|
|
||||||
model.title!!.back(ShellState(listOf(person), person.key).apply { pickerVisible = false; pickerLaunch = false })
|
|
||||||
assertEquals(listOf(Destination.Home(), Destination.Wolfskin(titleRoute(seriesId.toString()))), navigation.backStack.toList())
|
|
||||||
navigation.goBack()
|
|
||||||
assertEquals(listOf(Destination.Home()), navigation.backStack.toList())
|
|
||||||
navigation.navigateTo(Destination.Wolfskin(titleRoute(firstId.toString())))
|
|
||||||
open(firstId)
|
|
||||||
advanceUntilIdle()
|
|
||||||
model.title!!.click("episodes", 1)
|
|
||||||
navigation.goBack()
|
|
||||||
assertEquals(listOf(Destination.Home()), navigation.backStack.toList())
|
|
||||||
} finally {
|
|
||||||
model.viewModelScope.cancel()
|
|
||||||
session.close()
|
|
||||||
Dispatchers.resetMain()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun primarySeriesActionReResolvesNextUpButRestartAndExplicitEpisodesKeepTheirIdentity() = runTest {
|
|
||||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
|
||||||
val navigation = NavigationManager()
|
|
||||||
val facade = mockk<TitleReadFacade>()
|
|
||||||
coEvery { facade.readSaved(any(), any()) } returns null
|
|
||||||
coEvery { facade.read(any(), any(), any()) } coAnswers {
|
|
||||||
TitlePageData(series, first, watchTarget = if (firstArg<UUID>() == seriesId) series else first)
|
|
||||||
}
|
|
||||||
coEvery { facade.preparePlayback(seriesId) } returns BaseItemDto(id = secondId, type = BaseItemKind.EPISODE,
|
|
||||||
userData = playbackUserData(secondId, 11_362_740_000L, playCount = 2))
|
|
||||||
coEvery { facade.preparePlayback(firstId) } returns BaseItemDto(id = firstId, type = BaseItemKind.EPISODE,
|
|
||||||
userData = playbackUserData(firstId, 960_780_000L, playCount = 1))
|
|
||||||
val session = ConductarrSession(MemoryCacheStore())
|
|
||||||
val model = WolfskinTitleViewModel(RuntimeEnvironment.getApplication(), facade, mockk(), navigation, session, mockk<ServerRepository>(relaxed = true))
|
|
||||||
try {
|
|
||||||
model.load(titleRoute(seriesId.toString()), person, preferences, fixtures = false)
|
|
||||||
advanceUntilIdle()
|
|
||||||
model.title!!.click("header", 0)
|
|
||||||
advanceUntilIdle()
|
|
||||||
assertEquals(Destination.Playback(secondId, 1_136_274L), navigation.backStack.last())
|
|
||||||
navigation.goBack()
|
|
||||||
model.title!!.click("header", model.title!!.options.indexOfFirst { it.id == "restart" })
|
|
||||||
advanceUntilIdle()
|
|
||||||
assertEquals(Destination.Playback(firstId, 0L), navigation.backStack.last())
|
|
||||||
navigation.goBack()
|
|
||||||
model.load(titleRoute(firstId.toString()), person, preferences, fixtures = false)
|
|
||||||
advanceUntilIdle()
|
|
||||||
model.title!!.click("header", 0)
|
|
||||||
advanceUntilIdle()
|
|
||||||
assertEquals(Destination.Playback(firstId, 96_078L), navigation.backStack.last())
|
|
||||||
coVerify(exactly = 1) { facade.preparePlayback(seriesId) }
|
|
||||||
} finally {
|
|
||||||
model.viewModelScope.cancel()
|
|
||||||
session.close()
|
|
||||||
Dispatchers.resetMain()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -33,8 +33,6 @@ import com.github.damontecres.wholphin.test.TestTracks
|
||||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||||
import org.jellyfin.sdk.model.api.request.GetEpisodesRequest
|
import org.jellyfin.sdk.model.api.request.GetEpisodesRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
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.Assert.*
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
|
|
@ -130,7 +128,7 @@ class TitleReadFacadeTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun diskCopyPreservesFileAndNonContiguousStreamIndexesWithoutRatingsBeforeSignIn() = runTest {
|
@Test fun diskCopyPreservesFileAndNonContiguousStreamIndexesWithoutRatingsBeforeSignIn() = runTest {
|
||||||
coEvery { library.getItem(firstId) } returns successResponse(file(firstId).copy(userData = playbackUserData(firstId, 200_000_000L)))
|
coEvery { library.getItem(firstId) } returns successResponse(file(firstId))
|
||||||
facade.read(firstId, null, preferences)
|
facade.read(firstId, null, preferences)
|
||||||
coEvery { library.getItem(any()) } throws java.io.IOException("offline")
|
coEvery { library.getItem(any()) } throws java.io.IOException("offline")
|
||||||
val saved = facade.readSaved(firstId, preferences)!!
|
val saved = facade.readSaved(firstId, preferences)!!
|
||||||
|
|
@ -141,7 +139,6 @@ class TitleReadFacadeTest {
|
||||||
assertEquals(TitleConnection.SignedOut, saved.connection)
|
assertEquals(TitleConnection.SignedOut, saved.connection)
|
||||||
assertTrue(saved.item.ratings.isEmpty())
|
assertTrue(saved.item.ratings.isEmpty())
|
||||||
assertEquals(firstId.toString(), saved.watchTarget.id)
|
assertEquals(firstId.toString(), saved.watchTarget.id)
|
||||||
assertFalse(screen.options.any { it.id == "play" || it.id == "restart" })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun diskCopiesNeverCrossViewerIdentity() = runTest {
|
@Test fun diskCopiesNeverCrossViewerIdentity() = runTest {
|
||||||
|
|
@ -221,43 +218,6 @@ class TitleReadFacadeTest {
|
||||||
assertEquals(2, TitleState(data, ShellPerson("justin", "Justin", "J", false)) {}.season)
|
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 {
|
@Test fun compactJellyfinSourceIdUsesTheSameVersionPersistenceKey() = runTest {
|
||||||
coEvery { library.getItem(firstId) } returns successResponse(file(firstId))
|
coEvery { library.getItem(firstId) } returns successResponse(file(firstId))
|
||||||
coEvery { playback.savePlayVersion(any(), any()) } returns null
|
coEvery { playback.savePlayVersion(any(), any()) } returns null
|
||||||
|
|
|
||||||
|
|
@ -1,135 +0,0 @@
|
||||||
package org.justinvisser.wolfskin.tv
|
|
||||||
|
|
||||||
import android.app.Application
|
|
||||||
import android.content.Context
|
|
||||||
import com.github.damontecres.wholphin.WholphinApplication
|
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
|
||||||
import com.github.damontecres.wholphin.services.ImageUrlService
|
|
||||||
import com.github.damontecres.wholphin.ui.successQueryResult
|
|
||||||
import com.github.damontecres.wholphin.ui.successResponse
|
|
||||||
import io.mockk.*
|
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
|
||||||
import kotlinx.coroutines.cancel
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.test.*
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
|
||||||
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
|
||||||
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
|
|
||||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
|
||||||
import org.jellyfin.sdk.api.operations.ItemsApi
|
|
||||||
import org.jellyfin.sdk.api.operations.TvShowsApi
|
|
||||||
import org.jellyfin.sdk.api.operations.UserLibraryApi
|
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult
|
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
|
||||||
import org.junit.After
|
|
||||||
import org.junit.Assert.*
|
|
||||||
import org.junit.Before
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
|
|
||||||
import org.justinvisser.wolfskin.design.*
|
|
||||||
import org.justinvisser.wolfskin.tv.read.*
|
|
||||||
import org.robolectric.RobolectricTestRunner
|
|
||||||
import org.robolectric.RuntimeEnvironment
|
|
||||||
import org.robolectric.annotation.Config
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
|
||||||
import java.util.UUID
|
|
||||||
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
|
||||||
import com.github.damontecres.wholphin.util.configure
|
|
||||||
import com.github.damontecres.wholphin.util.reset
|
|
||||||
|
|
||||||
/** Recorded Jellyfin watch shapes exercise newer checkpoints and independent Home publication. */
|
|
||||||
@RunWith(RobolectricTestRunner::class)
|
|
||||||
@Config(sdk = [34], application = Application::class)
|
|
||||||
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
|
|
||||||
class WatchReadTest {
|
|
||||||
private val api = mockk<ApiClient>()
|
|
||||||
private val library = mockk<UserLibraryApi>()
|
|
||||||
private val shows = mockk<TvShowsApi>()
|
|
||||||
private val items = mockk<ItemsApi>()
|
|
||||||
private val images = mockk<ImageUrlService>(relaxed = true)
|
|
||||||
private val userId = UUID.fromString("167649f8-00c9-44b5-a86a-f371eccc2d2e")
|
|
||||||
private val seriesId = UUID.fromString("00000000-0000-0000-0000-000000000003")
|
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
|
||||||
// Selected fields from the read-only Resume and NextUp replies, without credentials.
|
|
||||||
private val resumeWire = """{"Items":[{"Id":"a8ed46755a50a5dfb443dd5cdd7125a3","Name":"Sex and the Country","Type":"Episode","ParentIndexNumber":4,"IndexNumber":9,"RunTimeTicks":17507200000,"UserData":{"PlaybackPositionTicks":960780000,"Played":false,"PlayCount":1,"IsFavorite":false,"LastPlayedDate":null,"Key":"","ItemId":"a8ed46755a50a5dfb443dd5cdd7125a3"}}],"TotalRecordCount":1,"StartIndex":0}"""
|
|
||||||
private val nextWire = """{"Items":[{"Id":"d6a9588e91a4a22ecb9ae279eb7e09d6","Name":"Me and My Town","Type":"Episode","SeriesName":"Desperate Housewives","ParentIndexNumber":5,"IndexNumber":9,"RunTimeTicks":25368960000,"UserData":{"PlaybackPositionTicks":11362740000,"Played":false,"PlayCount":2,"IsFavorite":false,"LastPlayedDate":"2026-11-06T10:39:27.1772471Z","Key":"","ItemId":"d6a9588e91a4a22ecb9ae279eb7e09d6"}}],"TotalRecordCount":1,"StartIndex":0}"""
|
|
||||||
private fun decode(wire: String) = json.decodeFromString(BaseItemDtoQueryResult.serializer(), wire).items.first()
|
|
||||||
|
|
||||||
@Before fun setup() {
|
|
||||||
every { api.userLibraryApi } returns library
|
|
||||||
every { api.tvShowsApi } returns shows
|
|
||||||
every { api.itemsApi } returns items
|
|
||||||
mockkObject(WholphinApplication.Companion)
|
|
||||||
every { WholphinApplication.instance } returns mockk {
|
|
||||||
every { resources } returns RuntimeEnvironment.getApplication().resources
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@After fun teardown() { unmockkObject(WholphinApplication.Companion) }
|
|
||||||
|
|
||||||
@Test fun bothFacadesUseTheLiveCheckpointAndSeriesNextUpWhileExplicitEpisodeKeepsItsIdentity() = runTest {
|
|
||||||
val resume = decode(resumeWire)
|
|
||||||
val next = decode(nextWire)
|
|
||||||
val series = BaseItemDto(id = seriesId, type = BaseItemKind.SERIES)
|
|
||||||
coEvery { library.getItem(itemId = seriesId, userId = userId) } returns successResponse(series)
|
|
||||||
coEvery { library.getItem(itemId = resume.id, userId = userId) } returns successResponse(resume)
|
|
||||||
coEvery { library.getItem(itemId = next.id, userId = userId) } returns successResponse(next)
|
|
||||||
coEvery { shows.getNextUp(userId = userId, seriesId = seriesId, limit = 1, enableResumable = true, enableUserData = true) } returns successQueryResult(listOf(next))
|
|
||||||
val home = HomeReadFacade(api, images, mockk())
|
|
||||||
val repository = mockk<ServerRepository> {
|
|
||||||
every { currentUser } returns mockk { every { id } returns userId }
|
|
||||||
}
|
|
||||||
val title = TitleReadFacade(api, images, mockk(), repository, mockk(), mockk<Context>())
|
|
||||||
val cached = HomeTitle(resume.id.toString(), "Sex and the Country", "episode", resumeSeconds = 10.0)
|
|
||||||
assertEquals(96_078L, home.preparePlayback(userId, cached)!!.playbackPositionMs())
|
|
||||||
assertEquals(96_078L, title.preparePlayback(resume.id)!!.playbackPositionMs())
|
|
||||||
assertEquals(next.id, home.preparePlayback(userId, HomeTitle(seriesId.toString(), "Series", "tv"))!!.id)
|
|
||||||
val selected = title.preparePlayback(seriesId)!!
|
|
||||||
assertEquals(next.id, selected.id)
|
|
||||||
assertEquals(1_136_274L, selected.playbackPositionMs())
|
|
||||||
coVerify(exactly = 2) { shows.getNextUp(userId = userId, seriesId = seriesId, limit = 1, enableResumable = true, enableUserData = true) }
|
|
||||||
val completed = resume.copy(userData = resume.userData!!.copy(playbackPositionTicks = 0, played = true))
|
|
||||||
coEvery { library.getItem(itemId = resume.id, userId = userId) } returns successResponse(completed)
|
|
||||||
assertEquals(0L, home.preparePlayback(userId, cached)!!.playbackPositionMs())
|
|
||||||
assertEquals(true, title.preparePlayback(resume.id)!!.userData?.played)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun freshResumeAndNextUpPaintBeforeSlowLibraryAndSurviveItsFailure() = runTest {
|
|
||||||
WholphinDispatchers.configure(StandardTestDispatcher(testScheduler))
|
|
||||||
val server = JellyfinServer(seriesId, "Fixture", "https://example.invalid", null)
|
|
||||||
val user = JellyfinUser(id = userId, serverId = server.id, name = "Mindel", accessToken = null)
|
|
||||||
val person = ShellPerson("mindel", "Mindel", "M", false, userId.toString())
|
|
||||||
val resume = decode(resumeWire)
|
|
||||||
val next = decode(nextWire)
|
|
||||||
coEvery { items.getResumeItems(userId = userId, fields = any(), mediaTypes = any(), limit = 20) } returns successQueryResult(listOf(resume))
|
|
||||||
coEvery { shows.getNextUp(userId = userId, fields = any(), limit = 20, enableUserData = true) } returns successQueryResult(listOf(next))
|
|
||||||
val facade = spyk(HomeReadFacade(api, images, mockk()))
|
|
||||||
val releaseLibrary = CompletableDeferred<Unit>()
|
|
||||||
coEvery { facade.readLibrary(userId) } coAnswers { releaseLibrary.await(); throw java.io.IOException("Library failed") }
|
|
||||||
val cache = mockk<HomeLibraryCache>(relaxed = true)
|
|
||||||
every { cache.read(any()) } returns (HomeLibrary(resume = listOf(HomeTitle(resume.id.toString(), "Cached episode", "episode", resumeSeconds = 10.0))) to 1L)
|
|
||||||
val session = ConductarrSession(MemoryCacheStore())
|
|
||||||
val model = WolfskinHomeViewModel(RuntimeEnvironment.getApplication(), facade, cache, NavigationManager(), WolfskinSeeAllLists(), session)
|
|
||||||
try {
|
|
||||||
val loading = launch { model.loadLibrary(server, user, person) }
|
|
||||||
runCurrent()
|
|
||||||
assertFalse(loading.isCompleted)
|
|
||||||
assertTrue(model.snapshot.rows.first().watchReady)
|
|
||||||
assertEquals(1136.274, model.snapshot.rows.single { it.id == "nu" }.cards.single().resumeSeconds, .0001)
|
|
||||||
releaseLibrary.complete(Unit)
|
|
||||||
loading.join()
|
|
||||||
assertEquals(96.078, model.snapshot.rows.first().cards.first().resumeSeconds, .0001)
|
|
||||||
assertTrue(model.snapshot.jellyfinAvailable)
|
|
||||||
} finally {
|
|
||||||
model.viewModelScope.cancel()
|
|
||||||
session.close()
|
|
||||||
WholphinDispatchers.reset()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
package org.justinvisser.wolfskin.tv
|
|
||||||
|
|
||||||
import android.app.Application
|
|
||||||
import android.os.Looper
|
|
||||||
import androidx.activity.ComponentActivity
|
|
||||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
|
||||||
import com.github.damontecres.wholphin.util.PlaybackWatchState
|
|
||||||
import io.mockk.coEvery
|
|
||||||
import io.mockk.every
|
|
||||||
import io.mockk.mockk
|
|
||||||
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.*
|
|
||||||
import org.justinvisser.wolfskin.tv.read.HomeLibraryCache
|
|
||||||
import org.justinvisser.wolfskin.tv.read.HomeReadFacade
|
|
||||||
import org.robolectric.RobolectricTestRunner
|
|
||||||
import org.robolectric.Shadows.shadowOf
|
|
||||||
import org.robolectric.annotation.Config
|
|
||||||
import java.util.UUID
|
|
||||||
import java.util.concurrent.atomic.AtomicReference
|
|
||||||
|
|
||||||
/** A still-composed Home must paint the checkpoint acknowledged after its first return read. */
|
|
||||||
@RunWith(RobolectricTestRunner::class)
|
|
||||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
|
||||||
class WatchReturnTest {
|
|
||||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
|
||||||
|
|
||||||
@Test fun acknowledgedStopRefreshesVisibleHeroWithoutRecreatingHomeOrLosingFocus() {
|
|
||||||
val server = JellyfinServer(UUID.randomUUID(), "Fixture", "https://example.invalid", null)
|
|
||||||
val user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Mindel", accessToken = null)
|
|
||||||
val person = ShellPerson("mindel", "Mindel", "M", false, user.id.toString())
|
|
||||||
val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; pickerLaunch = false }
|
|
||||||
val episode = HomeTitle("episode", "Me and My Town", "episode", runtimeSeconds = 2536, resumeSeconds = 10.0, held = true, seriesTitle = "Desperate Housewives", season = 5, episode = 9)
|
|
||||||
val current = AtomicReference(episode)
|
|
||||||
val facade = mockk<HomeReadFacade>()
|
|
||||||
every { facade.audiences } returns null
|
|
||||||
coEvery { facade.readWatch(user.id) } coAnswers { HomeLibrary(resume = listOf(current.get())) }
|
|
||||||
coEvery { facade.readLibrary(user.id) } returns HomeLibrary()
|
|
||||||
val cache = mockk<HomeLibraryCache>(relaxed = true)
|
|
||||||
every { cache.read(any()) } returns null
|
|
||||||
val session = ConductarrSession(MemoryCacheStore())
|
|
||||||
val model = WolfskinHomeViewModel(rule.activity, facade, cache, NavigationManager(), WolfskinSeeAllLists(), session)
|
|
||||||
val oldFixtures = ConductarrPreference.fixtures
|
|
||||||
ConductarrPreference.updateFixtures(false)
|
|
||||||
try {
|
|
||||||
rule.setContent { WolfskinTheme {
|
|
||||||
ShellSurface(shell) { HomeDestination(shell, server, user, it, model) }
|
|
||||||
} }
|
|
||||||
rule.waitUntil(5000) {
|
|
||||||
shadowOf(Looper.getMainLooper()).idle()
|
|
||||||
model.snapshot.rows.firstOrNull()?.cards?.firstOrNull()?.resumeSeconds == 10.0
|
|
||||||
}
|
|
||||||
val view = shell.rememberedView("home")
|
|
||||||
rule.runOnIdle { shell.navFocused = false; view.cards["hero"] = 0; current.set(episode.copy(resumeSeconds = 1136.274)) }
|
|
||||||
PlaybackWatchState.stopped(rule.activity, "${server.id}:${user.id}")
|
|
||||||
rule.waitUntil(5000) {
|
|
||||||
shadowOf(Looper.getMainLooper()).idle()
|
|
||||||
model.snapshot.rows.firstOrNull()?.cards?.firstOrNull()?.resumeSeconds == 1136.274
|
|
||||||
}
|
|
||||||
assertSame(view, shell.rememberedView("home"))
|
|
||||||
assertEquals(0, view.cards["hero"])
|
|
||||||
assertTrue(model.snapshot.rows.first().watchReady)
|
|
||||||
} finally {
|
|
||||||
model.viewModelScope.cancel()
|
|
||||||
session.close()
|
|
||||||
ConductarrPreference.updateFixtures(oldFixtures)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -21,7 +21,6 @@ import kotlinx.coroutines.CoroutineScope
|
||||||
import org.justinvisser.wolfskin.conductarr.CacheEntry
|
import org.justinvisser.wolfskin.conductarr.CacheEntry
|
||||||
import org.justinvisser.wolfskin.conductarr.CacheStore
|
import org.justinvisser.wolfskin.conductarr.CacheStore
|
||||||
import org.justinvisser.wolfskin.conductarr.FileCacheStore
|
import org.justinvisser.wolfskin.conductarr.FileCacheStore
|
||||||
import org.justinvisser.wolfskin.conductarr.PersistentDns
|
|
||||||
|
|
||||||
/** Advanced-only endpoint and fixture controls contain no credentials. */
|
/** Advanced-only endpoint and fixture controls contain no credentials. */
|
||||||
object ConductarrPreference {
|
object ConductarrPreference {
|
||||||
|
|
@ -55,8 +54,8 @@ object ConductarrPreference {
|
||||||
object ConductarrModule {
|
object ConductarrModule {
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun session(@ApplicationContext context: Context, @IoCoroutineScope scope: CoroutineScope, dns: PersistentDns): ConductarrSession =
|
fun session(@ApplicationContext context: Context, @IoCoroutineScope scope: CoroutineScope): ConductarrSession =
|
||||||
ConductarrSession(ConductarrDiskCache(context), scope, dns)
|
ConductarrSession(ConductarrDiskCache(context), scope)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Android may create noBackupFilesDir, so resolve it only during an IO cache operation. */
|
/** Android may create noBackupFilesDir, so resolve it only during an IO cache operation. */
|
||||||
|
|
|
||||||
|
|
@ -41,62 +41,20 @@ internal fun DiscoverTitle.homeTitle(): HomeTitle = HomeTitle(
|
||||||
overview = overview, poster = poster, backdrop = backdrop, genres = genres, tmdbId = tmdb.toInt(),
|
overview = overview, poster = poster, backdrop = backdrop, genres = genres, tmdbId = tmdb.toInt(),
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Activity is history; stage evidence must not turn a past dispatch into a live transfer. */
|
|
||||||
internal fun RequestProgress.comingIn(): ComingInRequest {
|
internal fun RequestProgress.comingIn(): ComingInRequest {
|
||||||
val stagesByName = stages.orEmpty().associateBy { it.name }
|
val stagesByName = stages.orEmpty().associateBy { it.name }
|
||||||
val acquisition = stagesByName[ReadinessStage.Name.ACQUISITION]
|
val acquisition = stagesByName[ReadinessStage.Name.ACQUISITION]
|
||||||
val visibility = stagesByName[ReadinessStage.Name.VISIBILITY]
|
val visibility = stagesByName[ReadinessStage.Name.VISIBILITY]
|
||||||
val playback = stagesByName[ReadinessStage.Name.PLAYBACK]
|
|
||||||
val stage = when {
|
val stage = when {
|
||||||
acquisition?.summary == "The title was deleted after it was dispatched." -> ComingInStage.Removed
|
stages.orEmpty().any { it.state == ReadinessStage.State.FAILED } -> ComingInStage.Failed
|
||||||
acquisition?.summary == "The title is not released yet; there is nothing to acquire." -> ComingInStage.NotOutYet
|
|
||||||
playback?.state == ReadinessStage.State.FAILED || playback?.state == ReadinessStage.State.CONFLICTING -> ComingInStage.FileHealth
|
|
||||||
visibility?.summary?.startsWith("The title is only in libraries this viewer does not see: ") == true -> ComingInStage.HeldElsewhere
|
|
||||||
visibility?.summary == "The title is not in a library this viewer sees." -> ComingInStage.NotInLibrary
|
|
||||||
visibility?.state == ReadinessStage.State.FAILED || visibility?.state == ReadinessStage.State.CONFLICTING -> ComingInStage.CheckNeeded
|
|
||||||
stagesByName[ReadinessStage.Name.REQUEST]?.state == ReadinessStage.State.FAILED || acquisition?.state == ReadinessStage.State.FAILED -> ComingInStage.Failed
|
|
||||||
visibility?.state == ReadinessStage.State.DONE -> ComingInStage.Ready
|
visibility?.state == ReadinessStage.State.DONE -> ComingInStage.Ready
|
||||||
awaitingApproval == true || acquisition?.summary == "Waiting for Seerr's approval to reach dispatch." -> ComingInStage.Waiting
|
awaitingApproval == true -> ComingInStage.Waiting
|
||||||
acquisition?.summary == "Dispatched; waiting for the Arr to import a file." -> ComingInStage.WaitingForFile
|
visibility?.state in setOf(ReadinessStage.State.IN_PROGRESS, ReadinessStage.State.PARTIAL) || acquisition?.state == ReadinessStage.State.DONE -> ComingInStage.AlmostReady
|
||||||
visibility?.state == ReadinessStage.State.IN_PROGRESS || visibility?.state == ReadinessStage.State.PARTIAL || acquisition?.state == ReadinessStage.State.DONE -> ComingInStage.AlmostReady
|
acquisition?.state == ReadinessStage.State.IN_PROGRESS -> ComingInStage.Downloading
|
||||||
acquisition?.state == ReadinessStage.State.IN_PROGRESS && acquisition.summary.startsWith("Downloading", ignoreCase = true) -> ComingInStage.Downloading
|
acquisition?.state == ReadinessStage.State.NONE -> ComingInStage.Searching
|
||||||
acquisition?.state == ReadinessStage.State.NONE && acquisition.summary.startsWith("Searching", ignoreCase = true) -> ComingInStage.Searching
|
stages.orEmpty().any { it.state in setOf(ReadinessStage.State.UNKNOWN, ReadinessStage.State.STALE, ReadinessStage.State.CONFLICTING) } -> ComingInStage.Checking
|
||||||
acquisition?.state == ReadinessStage.State.IN_PROGRESS || acquisition?.state == ReadinessStage.State.NONE -> ComingInStage.Waiting
|
else -> ComingInStage.Waiting
|
||||||
else -> ComingInStage.Checking
|
|
||||||
}
|
|
||||||
val detail = when (stage) {
|
|
||||||
ComingInStage.FileHealth -> playback
|
|
||||||
ComingInStage.CheckNeeded, ComingInStage.HeldElsewhere, ComingInStage.NotInLibrary, ComingInStage.Ready -> visibility
|
|
||||||
ComingInStage.Failed -> stages.orEmpty().firstOrNull { it.state == ReadinessStage.State.FAILED }
|
|
||||||
else -> acquisition
|
|
||||||
}
|
}
|
||||||
return ComingInRequest(id, mediaType.name.lowercase(), tmdbId, title ?: "Title unavailable", requester.key,
|
return ComingInRequest(id, mediaType.name.lowercase(), tmdbId, title ?: "Title unavailable", requester.key,
|
||||||
requester.name, actor?.requester, actor?.name, stage, summary = detail?.summary,
|
requester.name, actor?.requester, actor?.name, stage)
|
||||||
readiness = ReadinessStage.Name.entries.map { stagesByName[it].readinessWord() })
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Expanded readiness describes each observed stage, not a synthetic download failure. */
|
|
||||||
private fun ReadinessStage?.readinessWord(): String = when {
|
|
||||||
this == null -> "Unknown"
|
|
||||||
name == ReadinessStage.Name.PLAYBACK && (state == ReadinessStage.State.FAILED || state == ReadinessStage.State.CONFLICTING) -> "Needs attention"
|
|
||||||
name == ReadinessStage.Name.VISIBILITY && (state == ReadinessStage.State.FAILED || state == ReadinessStage.State.CONFLICTING) -> "Check needed"
|
|
||||||
name == ReadinessStage.Name.ACQUISITION && summary == "The title was deleted after it was dispatched." -> "Removed"
|
|
||||||
name == ReadinessStage.Name.ACQUISITION && summary == "The title is not released yet; there is nothing to acquire." -> "Not out yet"
|
|
||||||
name == ReadinessStage.Name.ACQUISITION && summary.startsWith("Dispatched; the Arr has represented files") -> "Files present"
|
|
||||||
name == ReadinessStage.Name.ACQUISITION && summary == "Dispatched; waiting for the Arr to import a file." -> "Waiting for a file"
|
|
||||||
name == ReadinessStage.Name.VISIBILITY && summary.startsWith("The title is only in libraries this viewer does not see: ") -> "Another library"
|
|
||||||
name == ReadinessStage.Name.VISIBILITY && summary == "The title is not in a library this viewer sees." -> "Not in library"
|
|
||||||
name == ReadinessStage.Name.ACQUISITION && state == ReadinessStage.State.IN_PROGRESS && summary.startsWith("Downloading", ignoreCase = true) -> "Downloading"
|
|
||||||
name == ReadinessStage.Name.PLAYBACK && (state == ReadinessStage.State.DONE || state == ReadinessStage.State.PARTIAL) -> "Recorded play"
|
|
||||||
name == ReadinessStage.Name.PLAYBACK && state == ReadinessStage.State.UNKNOWN -> "Not checked yet"
|
|
||||||
else -> when (state) {
|
|
||||||
ReadinessStage.State.DONE -> "Done"
|
|
||||||
ReadinessStage.State.FAILED -> "Failed"
|
|
||||||
ReadinessStage.State.IN_PROGRESS -> "In progress"
|
|
||||||
ReadinessStage.State.PARTIAL -> "Partial"
|
|
||||||
ReadinessStage.State.NONE -> "Not yet"
|
|
||||||
ReadinessStage.State.UNKNOWN -> "Unknown"
|
|
||||||
ReadinessStage.State.STALE -> "Last observed"
|
|
||||||
ReadinessStage.State.CONFLICTING -> "Check needed"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -57,14 +57,10 @@ class ConductarrProvider(val client: ConductarrClient) : ConductarrTitleProvider
|
||||||
val loader = catalogue?.takeIf { it.queries == queries } ?: ConductarrCatalogue(client, queries) { libraryAudiences }.also { catalogue = it }
|
val loader = catalogue?.takeIf { it.queries == queries } ?: ConductarrCatalogue(client, queries) { libraryAudiences }.also { catalogue = it }
|
||||||
val lanes = if (trigger == ReadTrigger.CACHE_ONLY) loader.saved() else loader.through(1, force = trigger == ReadTrigger.FORCE)
|
val lanes = if (trigger == ReadTrigger.CACHE_ONLY) loader.saved() else loader.through(1, force = trigger == ReadTrigger.FORCE)
|
||||||
val genres = options.flatMap { it.genres.orEmpty() }.associate { it.id to it.name }
|
val genres = options.flatMap { it.genres.orEmpty() }.associate { it.id to it.name }
|
||||||
val candidates = feed?.movies.orEmpty() + feed?.shows.orEmpty()
|
val picks = (feed?.movies.orEmpty() + feed?.shows.orEmpty()).map { it.discoverTitle(genres) }
|
||||||
val picks = candidates.map { it.discoverTitle(genres) }
|
val seeds = (feed?.movies.orEmpty() + feed?.shows.orEmpty()).flatMap { candidate ->
|
||||||
val seeds = candidates.flatMapIndexed { index, candidate ->
|
candidate.contributions.orEmpty().mapNotNull { seed -> seed.title?.let { it to candidate.discoverTitle(genres) } }
|
||||||
candidate.contributions.orEmpty().filter { !it.title.isNullOrBlank() }.map { it to picks[index] }
|
}.groupBy({ it.first }, { it.second }).map { DiscoverSeed(it.key, it.value) }
|
||||||
}.groupBy { (seed, _) -> "${seed.mediaType.name.lowercase()}:${seed.tmdbId}" }.map { (key, contributions) ->
|
|
||||||
DiscoverSeed(key, requireNotNull(contributions.first().first.title), contributions.map { it.second },
|
|
||||||
watched = contributions.any { it.first.watched }, added = contributions.any { it.first.added })
|
|
||||||
}
|
|
||||||
val hidden = hiddenJob.await()?.dismissals.orEmpty().map {
|
val hidden = hiddenJob.await()?.dismissals.orEmpty().map {
|
||||||
DiscoverTitle(it.tmdbId, it.mediaType.name.lowercase(), it.title, it.year.toInt(), poster = catalogueImage(it.posterPath))
|
DiscoverTitle(it.tmdbId, it.mediaType.name.lowercase(), it.title, it.year.toInt(), poster = catalogueImage(it.posterPath))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,6 @@ data class ConductarrIdentity(
|
||||||
class ConductarrSession(
|
class ConductarrSession(
|
||||||
private val cache: CacheStore,
|
private val cache: CacheStore,
|
||||||
private val disposalScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
private val disposalScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
||||||
private val dns: PersistentDns? = null,
|
|
||||||
) : Closeable {
|
) : Closeable {
|
||||||
private val mutableIdentity = MutableStateFlow<ConductarrIdentity?>(null)
|
private val mutableIdentity = MutableStateFlow<ConductarrIdentity?>(null)
|
||||||
val identity: StateFlow<ConductarrIdentity?> = mutableIdentity.asStateFlow()
|
val identity: StateFlow<ConductarrIdentity?> = mutableIdentity.asStateFlow()
|
||||||
|
|
@ -51,7 +50,7 @@ class ConductarrSession(
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
val client = if (token.isNullOrBlank()) null else 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) {
|
if (event == ClientEvent.NEEDS_SIGN_IN) {
|
||||||
mutableIdentity.update {
|
mutableIdentity.update {
|
||||||
if (generation == currentGeneration) it?.copy(operator = null, signedIn = false, error = ClientError.NeedsSignIn) else it
|
if (generation == currentGeneration) it?.copy(operator = null, signedIn = false, error = ClientError.NeedsSignIn) else it
|
||||||
|
|
|
||||||
|
|
@ -18,12 +18,10 @@ import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.coroutineScope
|
|
||||||
import org.justinvisser.wolfskin.tv.read.playbackPositionMs
|
|
||||||
import org.justinvisser.wolfskin.design.*
|
import org.justinvisser.wolfskin.design.*
|
||||||
import org.justinvisser.wolfskin.tv.read.HomeLibraryCache
|
import org.justinvisser.wolfskin.tv.read.HomeLibraryCache
|
||||||
import org.justinvisser.wolfskin.tv.read.HomeReadFacade
|
import org.justinvisser.wolfskin.tv.read.HomeReadFacade
|
||||||
|
|
@ -52,9 +50,9 @@ class WolfskinHomeViewModel @Inject constructor(
|
||||||
|
|
||||||
suspend fun load(server: JellyfinServer, user: JellyfinUser, person: ShellPerson, retry: Boolean = false) {
|
suspend fun load(server: JellyfinServer, user: JellyfinUser, person: ShellPerson, retry: Boolean = false) {
|
||||||
catalogueJob?.cancel()
|
catalogueJob?.cancel()
|
||||||
withContext(WholphinDispatchers.IO) { debugHomeSnapshot(context, person.key) }?.let { snapshot = it; return }
|
withContext(Dispatchers.IO) { debugHomeSnapshot(context, person.key) }?.let { snapshot = it; return }
|
||||||
if (retry && conductarr.identity.value?.signedIn != true) {
|
if (retry && conductarr.identity.value?.signedIn != true) {
|
||||||
withContext(WholphinDispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true)
|
conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -76,12 +74,11 @@ class WolfskinHomeViewModel @Inject constructor(
|
||||||
toast = null
|
toast = null
|
||||||
snapshot = HomeSnapshot(buildHomeRows(HomeLibrary(), emptyList(), loading = true), HomeAvailability.Loading)
|
snapshot = HomeSnapshot(buildHomeRows(HomeLibrary(), emptyList(), loading = true), HomeAvailability.Loading)
|
||||||
}
|
}
|
||||||
val saved = withContext(WholphinDispatchers.IO) { try { cache.read(scope) } catch (_: java.io.IOException) { null } }
|
val saved = withContext(Dispatchers.IO) { try { cache.read(scope) } catch (_: java.io.IOException) { null } }
|
||||||
if (revision != loadRevision) return
|
if (revision != loadRevision) return
|
||||||
var libraryCopy = saved?.first ?: HomeLibrary()
|
var libraryCopy = saved?.first ?: HomeLibrary()
|
||||||
var libraryAvailable = true
|
var libraryAvailable = true
|
||||||
var librarySavedAt: String? = null
|
var librarySavedAt: String? = null
|
||||||
var watchReady = false
|
|
||||||
val identity = conductarr.identity.value
|
val identity = conductarr.identity.value
|
||||||
var catalogueCopy = DiscoverSnapshot(status = if (identity?.signedIn == true || (retry && identity != null)) DiscoverStatus.Loading else DiscoverStatus.NeedsSignIn)
|
var catalogueCopy = DiscoverSnapshot(status = if (identity?.signedIn == true || (retry && identity != null)) DiscoverStatus.Loading else DiscoverStatus.NeedsSignIn)
|
||||||
fun publish() {
|
fun publish() {
|
||||||
|
|
@ -92,9 +89,8 @@ class WolfskinHomeViewModel @Inject constructor(
|
||||||
DiscoverStatus.Unavailable -> HomeAvailability.Unavailable
|
DiscoverStatus.Unavailable -> HomeAvailability.Unavailable
|
||||||
else -> if (catalogueCopy.savedAt != null) HomeAvailability.Saved else HomeAvailability.Ready
|
else -> if (catalogueCopy.savedAt != null) HomeAvailability.Saved else HomeAvailability.Ready
|
||||||
}
|
}
|
||||||
snapshot = HomeSnapshot(buildHomeRows(libraryCopy, catalogueCopy.homeRows(), libraryAvailable = libraryAvailable).map {
|
snapshot = HomeSnapshot(buildHomeRows(libraryCopy, catalogueCopy.homeRows(), libraryAvailable = libraryAvailable),
|
||||||
if (it.kind == HomeRowKind.Hero) it.copy(watchReady = watchReady) else it
|
status, libraryAvailable, catalogueCopy.savedAt ?: librarySavedAt)
|
||||||
}, status, libraryAvailable, catalogueCopy.savedAt ?: librarySavedAt)
|
|
||||||
}
|
}
|
||||||
catalogueSnapshot = catalogueCopy
|
catalogueSnapshot = catalogueCopy
|
||||||
publishCatalogue = { value -> if (revision == loadRevision && sourceScope == scope) { catalogueCopy = value; catalogueSnapshot = value; publish() } }
|
publishCatalogue = { value -> if (revision == loadRevision && sourceScope == scope) { catalogueCopy = value; catalogueSnapshot = value; publish() } }
|
||||||
|
|
@ -111,43 +107,20 @@ class WolfskinHomeViewModel @Inject constructor(
|
||||||
if (revision == loadRevision && conductarr.identity.value?.client === identity.client) publishCatalogue?.invoke(catalogue)
|
if (revision == loadRevision && conductarr.identity.value?.client === identity.client) publishCatalogue?.invoke(catalogue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
suspend fun save() {
|
try {
|
||||||
val library = libraryCopy
|
val library = withContext(Dispatchers.IO) { home.read(user.id) }
|
||||||
withContext(WholphinDispatchers.IO) {
|
val savedAt = System.currentTimeMillis()
|
||||||
try { cache.write(scope, library, System.currentTimeMillis()) } catch (_: java.io.IOException) { /* Disk failure cannot revoke fresh watch data. */ }
|
libraryCopy = library
|
||||||
}
|
publish()
|
||||||
|
withContext(Dispatchers.IO) { try { cache.write(scope, library, savedAt) } catch (_: java.io.IOException) { /* Saving cannot delay first paint or turn a reachable source into an outage. */ } }
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
if (exception is CancellationException) throw exception
|
||||||
|
if (revision != loadRevision) return
|
||||||
|
libraryAvailable = false
|
||||||
|
librarySavedAt = saved?.second?.let { SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(it)) }
|
||||||
|
publish()
|
||||||
|
if (retry) showToast(RecoveryMessage.StillUnreachable)
|
||||||
}
|
}
|
||||||
coroutineScope {
|
|
||||||
launch {
|
|
||||||
try {
|
|
||||||
val watch = withContext(WholphinDispatchers.IO) { home.readWatch(user.id) }
|
|
||||||
if (revision != loadRevision) return@launch
|
|
||||||
libraryCopy = libraryCopy.copy(resume = watch.resume, nextUp = watch.nextUp)
|
|
||||||
watchReady = true
|
|
||||||
libraryAvailable = true
|
|
||||||
publish()
|
|
||||||
} catch (exception: Exception) {
|
|
||||||
if (exception is CancellationException) throw exception
|
|
||||||
if (revision != loadRevision) return@launch
|
|
||||||
libraryAvailable = false
|
|
||||||
librarySavedAt = saved?.second?.let { SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(it)) }
|
|
||||||
publish()
|
|
||||||
if (retry) showToast(RecoveryMessage.StillUnreachable)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
launch {
|
|
||||||
try {
|
|
||||||
val shelves = withContext(WholphinDispatchers.IO) { home.readLibrary(user.id) }
|
|
||||||
if (revision != loadRevision) return@launch
|
|
||||||
libraryCopy = libraryCopy.copy(newest = shelves.newest, destinations = shelves.destinations, destinationTotals = shelves.destinationTotals)
|
|
||||||
publish()
|
|
||||||
} catch (exception: Exception) {
|
|
||||||
if (exception is CancellationException) throw exception
|
|
||||||
if (revision == loadRevision && retry) showToast(RecoveryMessage.StillUnreachable)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (revision == loadRevision) save()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun readAhead(lane: String?) {
|
suspend fun readAhead(lane: String?) {
|
||||||
|
|
@ -165,7 +138,6 @@ class WolfskinHomeViewModel @Inject constructor(
|
||||||
is HomeAction.SeeAll -> if (action.row.target in shell.tabs) openRoot(shell, action.row.target!!) else openCatalogueGrid(action.row, shell)
|
is HomeAction.SeeAll -> if (action.row.target in shell.tabs) openRoot(shell, action.row.target!!) else openCatalogueGrid(action.row, shell)
|
||||||
is HomeAction.Details -> navigation.navigateTo(Destination.Wolfskin(titleRoute(action.title.id)))
|
is HomeAction.Details -> navigation.navigateTo(Destination.Wolfskin(titleRoute(action.title.id)))
|
||||||
is HomeAction.Play -> viewModelScope.launch {
|
is HomeAction.Play -> viewModelScope.launch {
|
||||||
val personKey = shell.person.key
|
|
||||||
val selectedId = shell.person.jellyfinUserId
|
val selectedId = shell.person.jellyfinUserId
|
||||||
val matchesUser = if (selectedId != null) selectedId == user.id.toString() else
|
val matchesUser = if (selectedId != null) selectedId == user.id.toString() else
|
||||||
shell.person.key == user.name.orEmpty().lowercase() || (conductarr.identity.value?.person == "${server.id}:${user.id}" && shell.person.key == conductarr.identity.value?.operator?.requester)
|
shell.person.key == user.name.orEmpty().lowercase() || (conductarr.identity.value?.person == "${server.id}:${user.id}" && shell.person.key == conductarr.identity.value?.operator?.requester)
|
||||||
|
|
@ -174,9 +146,9 @@ class WolfskinHomeViewModel @Inject constructor(
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
val item = withContext(WholphinDispatchers.IO) { home.preparePlayback(user.id, action.title) }
|
val id = withContext(Dispatchers.IO) { home.playableId(user.id, action.title) }
|
||||||
if (item == null) showToast(RecoveryMessage.PlaybackUnavailable)
|
if (id == null) showToast(RecoveryMessage.PlaybackUnavailable)
|
||||||
else if (shell.person.key == personKey && (sourceScope == null || sourceScope == "${server.id}:${user.id}")) navigation.navigateTo(Destination.Playback(item.id, item.playbackPositionMs()))
|
else navigation.navigateTo(Destination.Playback(id, if (action.resume) (action.title.resumeSeconds * 1000).toLong() else 0L))
|
||||||
} catch (exception: Exception) {
|
} catch (exception: Exception) {
|
||||||
if (exception is CancellationException) throw exception
|
if (exception is CancellationException) throw exception
|
||||||
showToast(RecoveryMessage.PlaybackUnavailable)
|
showToast(RecoveryMessage.PlaybackUnavailable)
|
||||||
|
|
@ -232,8 +204,7 @@ internal fun HomeTitle.libraryTitle(): LibraryTitle = LibraryTitle(
|
||||||
@Composable
|
@Composable
|
||||||
fun HomeDestination(shell: ShellState, server: JellyfinServer, user: JellyfinUser, modifier: Modifier = Modifier, model: WolfskinHomeViewModel = hiltViewModel()) {
|
fun HomeDestination(shell: ShellState, server: JellyfinServer, user: JellyfinUser, modifier: Modifier = Modifier, model: WolfskinHomeViewModel = hiltViewModel()) {
|
||||||
val identity by model.conductarrState.collectAsState()
|
val identity by model.conductarrState.collectAsState()
|
||||||
val watchRefresh = rememberWatchRefresh("${server.id}:${user.id}")
|
LaunchedEffect(shell.person, server.id, user.id, identity?.client, identity?.signedIn, model.audiences) { model.load(server, user, shell.person) }
|
||||||
LaunchedEffect(shell.person, server.id, user.id, identity?.client, identity?.signedIn, model.audiences, watchRefresh) { model.load(server, user, shell.person) }
|
|
||||||
val view = shell.rememberedView("home")
|
val view = shell.rememberedView("home")
|
||||||
LaunchedEffect(view.row, model.snapshot.rows) {
|
LaunchedEffect(view.row, model.snapshot.rows) {
|
||||||
val lane = model.snapshot.rows.drop(view.row).take(3).lastOrNull { it.catalogue }?.id
|
val lane = model.snapshot.rows.drop(view.row).take(3).lastOrNull { it.catalogue }?.id
|
||||||
|
|
|
||||||
|
|
@ -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.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -28,6 +28,6 @@ internal suspend fun refreshRequestsWhileStarted(state: ShellState, refresh: ()
|
||||||
refresh()
|
refresh()
|
||||||
while (currentCoroutineContext().isActive) {
|
while (currentCoroutineContext().isActive) {
|
||||||
delay(WolfskinTokens.Behaviour.requestsPollSeconds.toLong() * 1000L)
|
delay(WolfskinTokens.Behaviour.requestsPollSeconds.toLong() * 1000L)
|
||||||
if (state.requestsVisible || state.requests.snapshot.hasUnsettledRequests) refresh()
|
if (state.requestsVisible || state.requests.snapshot.openCount > 0) refresh()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,6 @@ import org.justinvisser.wolfskin.design.TitleReadStatus
|
||||||
import org.justinvisser.wolfskin.design.TitleState
|
import org.justinvisser.wolfskin.design.TitleState
|
||||||
import org.justinvisser.wolfskin.design.TitleSurface
|
import org.justinvisser.wolfskin.design.TitleSurface
|
||||||
import org.justinvisser.wolfskin.tv.read.TitleReadFacade
|
import org.justinvisser.wolfskin.tv.read.TitleReadFacade
|
||||||
import org.justinvisser.wolfskin.tv.read.playbackPositionMs
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
|
@ -80,7 +79,6 @@ class WolfskinTitleViewModel @Inject constructor(
|
||||||
private val saveMutex = Mutex()
|
private val saveMutex = Mutex()
|
||||||
private var performerPage = 1
|
private var performerPage = 1
|
||||||
var conductarrAction: ((TitleAction) -> Unit)? = null
|
var conductarrAction: ((TitleAction) -> Unit)? = null
|
||||||
val watchScope: String get() = "${repository.currentServer?.id}:${repository.currentUser?.id}"
|
|
||||||
|
|
||||||
fun openRoute(route: String) = navigation.navigateTo(Destination.Wolfskin(route))
|
fun openRoute(route: String) = navigation.navigateTo(Destination.Wolfskin(route))
|
||||||
fun popRoute() = navigation.goBack()
|
fun popRoute() = navigation.goBack()
|
||||||
|
|
@ -89,16 +87,6 @@ class WolfskinTitleViewModel @Inject constructor(
|
||||||
/** Only an explicit preview/test choice admits assets; real catalogue ids use Conductarr. */
|
/** Only an explicit preview/test choice admits assets; real catalogue ids use Conductarr. */
|
||||||
fun load(destination: String, selected: ShellPerson, userPreferences: UserPreferences, fixtures: Boolean = BuildConfig.DEBUG, force: Boolean = false) {
|
fun load(destination: String, selected: ShellPerson, userPreferences: UserPreferences, fixtures: Boolean = BuildConfig.DEBUG, force: Boolean = false) {
|
||||||
if (!force && route == destination && ::viewer.isInitialized && viewer.key == selected.key) return
|
if (!force && route == destination && ::viewer.isInitialized && viewer.key == selected.key) return
|
||||||
if (force && route == destination && ::viewer.isInitialized && viewer.key == selected.key &&
|
|
||||||
title?.data?.status == TitleReadStatus.Ready && title?.data?.item?.held == true &&
|
|
||||||
(title?.data?.connection != TitleConnection.SignedIn || conductarr.identity.value?.signedIn == true) &&
|
|
||||||
!destination.startsWith("person/") && fixture == null) {
|
|
||||||
loadJob?.cancel()
|
|
||||||
preferences = userPreferences
|
|
||||||
title?.let { it.update(it.data.copy(watchReady = false)) }
|
|
||||||
loadJob = viewModelScope.launch { readTitle(Uri.decode(destination.substringAfter('/'))) }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
route = destination
|
route = destination
|
||||||
loadJob?.cancel()
|
loadJob?.cancel()
|
||||||
person = null
|
person = null
|
||||||
|
|
@ -144,7 +132,7 @@ class WolfskinTitleViewModel @Inject constructor(
|
||||||
if (title !== current) return
|
if (title !== current) return
|
||||||
if (heldId != null) {
|
if (heldId != null) {
|
||||||
if (current.data.status != TitleReadStatus.Ready) facade.readSaved(heldId, preferences)?.let { current.update(it) }
|
if (current.data.status != TitleReadStatus.Ready) facade.readSaved(heldId, preferences)?.let { current.update(it) }
|
||||||
held = facade.read(heldId, current.requestedSeason, preferences)
|
held = facade.read(heldId, current.takeIf { it.data.status == TitleReadStatus.Ready }?.season, preferences)
|
||||||
current.update(held)
|
current.update(held)
|
||||||
}
|
}
|
||||||
} catch (exception: Exception) {
|
} catch (exception: Exception) {
|
||||||
|
|
@ -235,21 +223,13 @@ class WolfskinTitleViewModel @Inject constructor(
|
||||||
when (action) {
|
when (action) {
|
||||||
TitleAction.Pop -> navigation.goBack()
|
TitleAction.Pop -> navigation.goBack()
|
||||||
is TitleAction.Person -> navigation.navigateTo(action.person.id.toUuidOrNull()?.let { Destination.MediaItem(it, org.jellyfin.sdk.model.api.BaseItemKind.PERSON) } ?: Destination.Wolfskin(personRoute(action.person.id)))
|
is TitleAction.Person -> navigation.navigateTo(action.person.id.toUuidOrNull()?.let { Destination.MediaItem(it, org.jellyfin.sdk.model.api.BaseItemKind.PERSON) } ?: Destination.Wolfskin(personRoute(action.person.id)))
|
||||||
is TitleAction.OpenTitle -> {
|
is TitleAction.OpenTitle -> navigation.navigateTo(action.item.id.toUuidOrNull()?.let { Destination.MediaItem(it, when (action.item.kind) {
|
||||||
val destination = action.item.id.toUuidOrNull()?.let { Destination.MediaItem(it, when (action.item.kind) {
|
"movie" -> org.jellyfin.sdk.model.api.BaseItemKind.MOVIE
|
||||||
"movie" -> org.jellyfin.sdk.model.api.BaseItemKind.MOVIE
|
"episode" -> org.jellyfin.sdk.model.api.BaseItemKind.EPISODE
|
||||||
"episode" -> org.jellyfin.sdk.model.api.BaseItemKind.EPISODE
|
else -> org.jellyfin.sdk.model.api.BaseItemKind.SERIES
|
||||||
else -> org.jellyfin.sdk.model.api.BaseItemKind.SERIES
|
}) } ?: Destination.Wolfskin(titleRoute(action.item.id)))
|
||||||
}) } ?: Destination.Wolfskin(titleRoute(action.item.id))
|
is TitleAction.Play -> play(action.item.id.toUuidOrNull(), if (action.restart) 0 else action.item.resumeSeconds * 1000)
|
||||||
if (action.episodeSelection && title?.data?.watchTarget?.kind == "episode") navigation.replaceTop(destination)
|
is TitleAction.Trailer -> if (action.url.startsWith("jellyfin:")) play(action.url.removePrefix("jellyfin:").toUuidOrNull(), 0)
|
||||||
else navigation.navigateTo(destination)
|
|
||||||
}
|
|
||||||
is TitleAction.Play -> {
|
|
||||||
val opened = title?.data?.watchTarget
|
|
||||||
play((if (action.restart || opened?.kind != "tv") action.item.id else opened.id).toUuidOrNull(),
|
|
||||||
restart = action.restart)
|
|
||||||
}
|
|
||||||
is TitleAction.Trailer -> if (action.url.startsWith("jellyfin:")) play(action.url.removePrefix("jellyfin:").toUuidOrNull(), restart = true)
|
|
||||||
else if (!TitleTrailer(context).open(action.url)) title?.toast = "No YouTube app is available to play this trailer."
|
else if (!TitleTrailer(context).open(action.url)) title?.toast = "No YouTube app is available to play this trailer."
|
||||||
is TitleAction.Version, is TitleAction.Track, is TitleAction.Watched -> savePlayback(action)
|
is TitleAction.Version, is TitleAction.Track, is TitleAction.Watched -> savePlayback(action)
|
||||||
is TitleAction.Season -> {
|
is TitleAction.Season -> {
|
||||||
|
|
@ -281,15 +261,12 @@ class WolfskinTitleViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun play(id: UUID?, restart: Boolean) {
|
private fun play(id: UUID?, position: Long) {
|
||||||
val current = title
|
|
||||||
val playbackScope = watchScope
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
if (id == null) throw IllegalArgumentException("No Jellyfin playback identity")
|
if (id == null) throw IllegalArgumentException("No Jellyfin playback identity")
|
||||||
val item = facade.preparePlayback(id) ?: throw IllegalArgumentException("No playable item")
|
facade.preparePlayback(id)
|
||||||
if (title !== current || watchScope != playbackScope) return@launch
|
navigation.navigateTo(Destination.Playback(id, position))
|
||||||
navigation.navigateTo(Destination.Playback(item.id, if (restart) 0L else item.playbackPositionMs()))
|
|
||||||
} catch (exception: Exception) {
|
} catch (exception: Exception) {
|
||||||
if (exception is CancellationException) throw exception
|
if (exception is CancellationException) throw exception
|
||||||
title?.toast = RecoveryMessage.PlaybackUnavailable
|
title?.toast = RecoveryMessage.PlaybackUnavailable
|
||||||
|
|
@ -331,8 +308,7 @@ class WolfskinTitleViewModel @Inject constructor(
|
||||||
@Composable
|
@Composable
|
||||||
fun TitleDestination(route: String, shell: ShellState, preferences: UserPreferences, modifier: Modifier = Modifier, viewModel: WolfskinTitleViewModel = hiltViewModel(), fixtures: Boolean = ConductarrPreference.fixtures) {
|
fun TitleDestination(route: String, shell: ShellState, preferences: UserPreferences, modifier: Modifier = Modifier, viewModel: WolfskinTitleViewModel = hiltViewModel(), fixtures: Boolean = ConductarrPreference.fixtures) {
|
||||||
val identity by viewModel.conductarr.identity.collectAsStateWithLifecycle()
|
val identity by viewModel.conductarr.identity.collectAsStateWithLifecycle()
|
||||||
val watchRefresh = rememberWatchRefresh(viewModel.watchScope)
|
LaunchedEffect(route, shell.person.key, identity?.client, identity?.signedIn, fixtures) { viewModel.load(route, shell.person, preferences, fixtures, force = true) }
|
||||||
LaunchedEffect(route, shell.person.key, identity?.client, identity?.signedIn, fixtures, watchRefresh) { viewModel.load(route, shell.person, preferences, fixtures, force = true) }
|
|
||||||
val title = viewModel.title?.takeIf { it.person.key == shell.person.key }
|
val title = viewModel.title?.takeIf { it.person.key == shell.person.key }
|
||||||
val requests = if (route.startsWith("title/")) rememberTitleRequests(viewModel, shell, route, identity, fixtures, title) else null
|
val requests = if (route.startsWith("title/")) rememberTitleRequests(viewModel, shell, route, identity, fixtures, title) else null
|
||||||
LaunchedEffect(title?.data?.status) {
|
LaunchedEffect(title?.data?.status) {
|
||||||
|
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
package org.justinvisser.wolfskin.tv
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.lifecycle.Lifecycle
|
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
|
||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
|
||||||
import com.github.damontecres.wholphin.util.PlaybackWatchState
|
|
||||||
|
|
||||||
/** Re-read on entry, foreground and successful stop, even when a screen remains composed. */
|
|
||||||
@Composable
|
|
||||||
internal fun rememberWatchRefresh(scope: String?): Pair<Long, Int> {
|
|
||||||
val revisions by PlaybackWatchState.revisions.collectAsState()
|
|
||||||
var foreground by remember(scope) { mutableIntStateOf(0) }
|
|
||||||
val owner = LocalLifecycleOwner.current
|
|
||||||
DisposableEffect(owner, scope) {
|
|
||||||
var initialResume = owner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)
|
|
||||||
val observer = LifecycleEventObserver { _, event ->
|
|
||||||
if (event == Lifecycle.Event.ON_RESUME) {
|
|
||||||
if (initialResume) initialResume = false else foreground++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
owner.lifecycle.addObserver(observer)
|
|
||||||
onDispose { owner.lifecycle.removeObserver(observer) }
|
|
||||||
}
|
|
||||||
return (revisions[scope] ?: 0L) to foreground
|
|
||||||
}
|
|
||||||
|
|
@ -63,7 +63,6 @@ import org.justinvisser.wolfskin.design.DiscoverScreen
|
||||||
class WolfskinSession @Inject constructor() {
|
class WolfskinSession @Inject constructor() {
|
||||||
var state by mutableStateOf<ShellState?>(null)
|
var state by mutableStateOf<ShellState?>(null)
|
||||||
var serverId: String? = null
|
var serverId: String? = null
|
||||||
internal var fixtureMode: Boolean? = null
|
|
||||||
var onAction: (ShellAction) -> Unit = {}
|
var onAction: (ShellAction) -> Unit = {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -91,6 +90,7 @@ class WolfskinShellViewModel @Inject constructor(
|
||||||
private var identityJob: kotlinx.coroutines.Job? = null
|
private var identityJob: kotlinx.coroutines.Job? = null
|
||||||
private var personSwitchJob: kotlinx.coroutines.Job? = null
|
private var personSwitchJob: kotlinx.coroutines.Job? = null
|
||||||
private val personSwitchMutex = Mutex()
|
private val personSwitchMutex = Mutex()
|
||||||
|
private var fixtureMode: Boolean? = null
|
||||||
|
|
||||||
suspend fun load(server: JellyfinServer, user: JellyfinUser) {
|
suspend fun load(server: JellyfinServer, user: JellyfinUser) {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
|
|
@ -103,9 +103,9 @@ class WolfskinShellViewModel @Inject constructor(
|
||||||
else withContext(Dispatchers.IO) { conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl) }
|
else withContext(Dispatchers.IO) { conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl) }
|
||||||
signedInPerson = session.state?.people?.firstOrNull { it.jellyfinUserId == user.id.toString() }?.key ?: user.name.orEmpty().lowercase()
|
signedInPerson = session.state?.people?.firstOrNull { it.jellyfinUserId == user.id.toString() }?.key ?: user.name.orEmpty().lowercase()
|
||||||
session.onAction = { action -> handleAction(action, server, user) }
|
session.onAction = { action -> handleAction(action, server, user) }
|
||||||
if (session.state == null || session.serverId != server.id.toString() || session.fixtureMode != ConductarrPreference.fixtures) {
|
if (session.state == null || session.serverId != server.id.toString() || fixtureMode != ConductarrPreference.fixtures) {
|
||||||
session.serverId = server.id.toString()
|
session.serverId = server.id.toString()
|
||||||
session.fixtureMode = ConductarrPreference.fixtures
|
fixtureMode = ConductarrPreference.fixtures
|
||||||
val people = withContext(Dispatchers.IO) { readShellPeople(context, listOf(user)) }
|
val people = withContext(Dispatchers.IO) { readShellPeople(context, listOf(user)) }
|
||||||
session.state = ShellState(people, signedInPerson) { session.onAction(it) }
|
session.state = ShellState(people, signedInPerson) { session.onAction(it) }
|
||||||
val saved = withContext(Dispatchers.IO) { repository.serverDao.getServer(server.id)?.users.orEmpty() }
|
val saved = withContext(Dispatchers.IO) { repository.serverDao.getServer(server.id)?.users.orEmpty() }
|
||||||
|
|
|
||||||
|
|
@ -72,8 +72,7 @@ fun WolfskinLibraryContent(
|
||||||
ShellPlaceholder("See all list unavailable", modifier)
|
ShellPlaceholder("See all list unavailable", modifier)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val watchRefresh = rememberWatchRefresh(cacheScope)
|
LaunchedEffect(grid, grid.refreshRevision, fixture, if (route in LIBRARY_DESTINATIONS) facade.audiences else null) {
|
||||||
LaunchedEffect(grid, grid.refreshRevision, fixture, if (route in LIBRARY_DESTINATIONS) facade.audiences else null, watchRefresh) {
|
|
||||||
if (route in LIBRARY_DESTINATIONS && (!ConductarrPreference.fixtures || fixture != null)) {
|
if (route in LIBRARY_DESTINATIONS && (!ConductarrPreference.fixtures || fixture != null)) {
|
||||||
val wasLoading = grid.status == LibraryStatus.Loading
|
val wasLoading = grid.status == LibraryStatus.Loading
|
||||||
if (wasLoading && !shell.navFocused) shell.focusNav(shell.currentTab)
|
if (wasLoading && !shell.navFocused) shell.focusNav(shell.currentTab)
|
||||||
|
|
@ -83,7 +82,7 @@ fun WolfskinLibraryContent(
|
||||||
grid.status = LibraryStatus.Ready
|
grid.status = LibraryStatus.Ready
|
||||||
} else {
|
} else {
|
||||||
val store = withContext(Dispatchers.IO) { snapshots.value }
|
val store = withContext(Dispatchers.IO) { snapshots.value }
|
||||||
store.read(cacheScope, route, force = grid.refreshRevision > 0, invalidatedAtMillis = watchRefresh.first) { facade.readLibrary(user.id, route) }.collect { snapshot ->
|
store.read(cacheScope, route, force = grid.refreshRevision > 0) { facade.readLibrary(user.id, route) }.collect { snapshot ->
|
||||||
val focusedId = grid.focusedTitle?.id
|
val focusedId = grid.focusedTitle?.id
|
||||||
grid.source = snapshot.items
|
grid.source = snapshot.items
|
||||||
focusedId?.let { id -> grid.items.indexOfFirst { it.id == id }.takeIf { it >= 0 }?.let { grid.index = it } }
|
focusedId?.let { id -> grid.items.indexOfFirst { it.id == id }.takeIf { it >= 0 }?.let { grid.index = it } }
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,7 @@ import org.justinvisser.wolfskin.design.HomeTitle
|
||||||
/** All Home SDK calls and image URLs stay behind this facade, with their own required field list. */
|
/** All Home SDK calls and image URLs stay behind this facade, with their own required field list. */
|
||||||
class HomeReadFacade @Inject constructor(private val api: ApiClient, private val images: ImageUrlService, private val libraries: LibraryReadFacade) {
|
class HomeReadFacade @Inject constructor(private val api: ApiClient, private val images: ImageUrlService, private val libraries: LibraryReadFacade) {
|
||||||
val audiences get() = libraries.audiences
|
val audiences get() = libraries.audiences
|
||||||
/** Watch rows are publishable without waiting for any library shelf. */
|
suspend fun read(userId: UUID): HomeLibrary {
|
||||||
suspend fun readWatch(userId: UUID): HomeLibrary {
|
|
||||||
val resumeItems = api.itemsApi.getResumeItems(userId = userId, fields = FIELDS, mediaTypes = listOf(MediaType.VIDEO), limit = 20).content.items
|
val resumeItems = api.itemsApi.getResumeItems(userId = userId, fields = FIELDS, mediaTypes = listOf(MediaType.VIDEO), limit = 20).content.items
|
||||||
val nextItems = api.tvShowsApi.getNextUp(userId = userId, fields = FIELDS, limit = 20, enableUserData = true).content.items
|
val nextItems = api.tvShowsApi.getNextUp(userId = userId, fields = FIELDS, limit = 20, enableUserData = true).content.items
|
||||||
val series = (resumeItems + nextItems).mapNotNull { it.seriesId }.distinct().associateWith { id ->
|
val series = (resumeItems + nextItems).mapNotNull { it.seriesId }.distinct().associateWith { id ->
|
||||||
|
|
@ -36,11 +35,6 @@ class HomeReadFacade @Inject constructor(private val api: ApiClient, private val
|
||||||
}
|
}
|
||||||
val resume = resumeItems.map { title(it, series) }
|
val resume = resumeItems.map { title(it, series) }
|
||||||
val next = nextItems.map { title(it, series) }
|
val next = nextItems.map { title(it, series) }
|
||||||
return HomeLibrary(resume = resume, nextUp = next)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Library failures do not discard an already successful watch read. */
|
|
||||||
suspend fun readLibrary(userId: UUID): HomeLibrary {
|
|
||||||
val libraries = api.userViewsApi.getUserViews(userId = userId).content.items
|
val libraries = api.userViewsApi.getUserViews(userId = userId).content.items
|
||||||
val destinations = linkedMapOf<String, MutableList<HomeTitle>>()
|
val destinations = linkedMapOf<String, MutableList<HomeTitle>>()
|
||||||
val newest = mutableListOf<HomeTitle>()
|
val newest = mutableListOf<HomeTitle>()
|
||||||
|
|
@ -52,18 +46,23 @@ class HomeReadFacade @Inject constructor(private val api: ApiClient, private val
|
||||||
totals[key] = (totals[key] ?: 0) + response.totalRecordCount
|
totals[key] = (totals[key] ?: 0) + response.totalRecordCount
|
||||||
newest.addAll(api.itemsApi.getItems(userId = userId, parentId = library.id, recursive = true, includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), fields = FIELDS, sortBy = listOf(ItemSortBy.DATE_CREATED), sortOrder = listOf(SortOrder.DESCENDING), limit = 12).content.items.map { title(it) })
|
newest.addAll(api.itemsApi.getItems(userId = userId, parentId = library.id, recursive = true, includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), fields = FIELDS, sortBy = listOf(ItemSortBy.DATE_CREATED), sortOrder = listOf(SortOrder.DESCENDING), limit = 12).content.items.map { title(it) })
|
||||||
}
|
}
|
||||||
return HomeLibrary(newest = newest.sortedByDescending { it.addedAt }.take(12), destinations = destinations, destinationTotals = totals)
|
return HomeLibrary(resume, next, newest.sortedByDescending { it.addedAt }.take(12), destinations, totals)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve the current checkpoint even when the hero was painted from disk. */
|
suspend fun playableId(userId: UUID, title: HomeTitle): UUID? {
|
||||||
suspend fun preparePlayback(userId: UUID, title: HomeTitle): BaseItemDto? {
|
title.id.let { runCatching { UUID.fromString(it) }.getOrNull() }?.let { id ->
|
||||||
val id = runCatching { UUID.fromString(title.id) }.getOrNull() ?: run {
|
val item = api.userLibraryApi.getItem(userId = userId, itemId = id).content
|
||||||
val kinds = if (title.isEpisode) listOf(BaseItemKind.EPISODE) else listOf(BaseItemKind.MOVIE)
|
if (item.type == BaseItemKind.SERIES) {
|
||||||
api.itemsApi.getItems(userId = userId, recursive = true, searchTerm = title.title, includeItemTypes = kinds, fields = FIELDS, limit = 20).content.items.firstOrNull {
|
return (api.tvShowsApi.getNextUp(userId = userId, seriesId = id, limit = 1).content.items.firstOrNull()
|
||||||
it.name == title.title && (!title.isEpisode || (it.seriesName == title.seriesTitle && it.parentIndexNumber == title.season && it.indexNumber == title.episode))
|
?: api.tvShowsApi.getEpisodes(seriesId = id, userId = userId, limit = 1).content.items.firstOrNull())
|
||||||
}?.id
|
?.takeIf { it.type == BaseItemKind.EPISODE }?.id
|
||||||
} ?: return null
|
}
|
||||||
return readPlayback(api, userId, id)
|
return item.id.takeIf { item.type == BaseItemKind.MOVIE || item.type == BaseItemKind.EPISODE }
|
||||||
|
}
|
||||||
|
val kinds = if (title.isEpisode) listOf(BaseItemKind.EPISODE) else listOf(BaseItemKind.MOVIE)
|
||||||
|
return api.itemsApi.getItems(userId = userId, recursive = true, searchTerm = title.title, includeItemTypes = kinds, fields = FIELDS, limit = 20).content.items.firstOrNull {
|
||||||
|
it.name == title.title && (!title.isEpisode || (it.seriesName == title.seriesTitle && it.parentIndexNumber == title.season && it.indexNumber == title.episode))
|
||||||
|
}?.id
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun title(item: BaseItemDto, series: Map<UUID, Int?> = emptyMap()): HomeTitle {
|
private fun title(item: BaseItemDto, series: Map<UUID, Int?> = emptyMap()): HomeTitle {
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@ class LibraryReadFacade @Inject constructor(private val api: ApiClient) {
|
||||||
val response = api.itemsApi.getItems(
|
val response = api.itemsApi.getItems(
|
||||||
userId = userId, parentId = library.id, recursive = true,
|
userId = userId, parentId = library.id, recursive = true,
|
||||||
includeItemTypes = listOf(if (destination == "movies") BaseItemKind.MOVIE else BaseItemKind.SERIES),
|
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,
|
sortBy = listOf(ItemSortBy.SORT_NAME), startIndex = offset,
|
||||||
limit = WolfskinTokens.Behaviour.libraryChunk.toInt(),
|
limit = WolfskinTokens.Behaviour.libraryChunk.toInt(),
|
||||||
).content
|
).content
|
||||||
|
|
|
||||||
|
|
@ -19,13 +19,13 @@ data class LibrarySnapshot(val items: List<LibraryTitle>, val savedAtMillis: Lon
|
||||||
|
|
||||||
/** Library snapshots share the existing atomic disk store, partitioned by server and person. */
|
/** Library snapshots share the existing atomic disk store, partitioned by server and person. */
|
||||||
class LibrarySnapshotStore(private val cache: CacheStore, private val now: () -> Long = System::currentTimeMillis) {
|
class LibrarySnapshotStore(private val cache: CacheStore, private val now: () -> Long = System::currentTimeMillis) {
|
||||||
fun read(scope: String, destination: String, force: Boolean = false, invalidatedAtMillis: Long = 0L, load: () -> Flow<List<LibraryTitle>>): Flow<LibrarySnapshot> = flow {
|
fun read(scope: String, destination: String, force: Boolean = false, load: () -> Flow<List<LibraryTitle>>): Flow<LibrarySnapshot> = flow {
|
||||||
val key = "library:$destination"
|
val key = "library:$destination"
|
||||||
val saved = try { cache.read(scope, key) } catch (_: IOException) { null }
|
val saved = try { cache.read(scope, key) } catch (_: IOException) { null }
|
||||||
val titles = saved?.let { try { decode(it.body) } catch (_: JSONException) { null } }
|
val titles = saved?.let { try { decode(it.body) } catch (_: JSONException) { null } }
|
||||||
if (saved != null && titles != null) emit(LibrarySnapshot(titles, saved.savedAtMillis, false))
|
if (saved != null && titles != null) emit(LibrarySnapshot(titles, saved.savedAtMillis, false))
|
||||||
val lifetime = (WolfskinTokens.Behaviour.libraryRefreshMinutes * 60_000).toLong()
|
val lifetime = (WolfskinTokens.Behaviour.libraryRefreshMinutes * 60_000).toLong()
|
||||||
if (!force && saved != null && titles != null && saved.validatedAtMillis > invalidatedAtMillis && now() - saved.validatedAtMillis < lifetime) return@flow
|
if (!force && saved != null && titles != null && now() - saved.validatedAtMillis < lifetime) return@flow
|
||||||
var current: List<LibraryTitle> = emptyList()
|
var current: List<LibraryTitle> = emptyList()
|
||||||
try {
|
try {
|
||||||
load().collect { chunk ->
|
load().collect { chunk ->
|
||||||
|
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
package org.justinvisser.wolfskin.tv.read
|
|
||||||
|
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
|
||||||
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
|
|
||||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
/** A click resolves its target and checkpoint from Jellyfin, never from a painted projection. */
|
|
||||||
internal suspend fun readPlayback(api: ApiClient, userId: UUID?, id: UUID): BaseItemDto? {
|
|
||||||
val opened = api.userLibraryApi.getItem(itemId = id, userId = userId).content
|
|
||||||
val target = if (opened.type == BaseItemKind.SERIES) {
|
|
||||||
api.tvShowsApi.getNextUp(userId = userId, seriesId = id, limit = 1, enableResumable = true, enableUserData = true)
|
|
||||||
.content.items.firstOrNull()?.id
|
|
||||||
?: api.tvShowsApi.getEpisodes(seriesId = id, userId = userId, limit = 1, enableUserData = true).content.items.firstOrNull()?.id
|
|
||||||
} else opened.id
|
|
||||||
val item = if (target == opened.id) opened else target?.let { api.userLibraryApi.getItem(itemId = it, userId = userId).content }
|
|
||||||
return item?.takeIf { it.type == BaseItemKind.EPISODE || it.type == BaseItemKind.MOVIE }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Preserve subsecond server checkpoints, including resumable rewatches marked Played. */
|
|
||||||
internal fun BaseItemDto.playbackPositionMs(): Long = (userData?.playbackPositionTicks ?: 0L) / 10_000L
|
|
||||||
|
|
@ -35,7 +35,6 @@ import org.justinvisser.wolfskin.design.TitlePageData
|
||||||
import org.justinvisser.wolfskin.design.TitlePerson
|
import org.justinvisser.wolfskin.design.TitlePerson
|
||||||
import org.justinvisser.wolfskin.design.TitleTrack
|
import org.justinvisser.wolfskin.design.TitleTrack
|
||||||
import org.justinvisser.wolfskin.design.TitleVersion
|
import org.justinvisser.wolfskin.design.TitleVersion
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
@ -53,8 +52,7 @@ class TitleReadFacade @Inject constructor(
|
||||||
@param:ApplicationContext private val context: Context,
|
@param:ApplicationContext private val context: Context,
|
||||||
) {
|
) {
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
private val episodeFields = listOf(ItemFields.PROVIDER_IDS, ItemFields.OVERVIEW)
|
private val fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.MEDIA_SOURCES, ItemFields.MEDIA_STREAMS, ItemFields.OVERVIEW, ItemFields.PEOPLE, ItemFields.PARENT_ID)
|
||||||
private val creditFields = listOf(ItemFields.PROVIDER_IDS, ItemFields.OVERVIEW, ItemFields.PEOPLE)
|
|
||||||
|
|
||||||
/** Resolve through this user's Jellyfin permissions before granting a catalogue card playback. */
|
/** Resolve through this user's Jellyfin permissions before granting a catalogue card playback. */
|
||||||
suspend fun findHeld(tmdb: Long, media: String): UUID? {
|
suspend fun findHeld(tmdb: Long, media: String): UUID? {
|
||||||
|
|
@ -78,7 +76,7 @@ class TitleReadFacade @Inject constructor(
|
||||||
val rows = json.decodeFromString(ListSerializer(BaseItemDto.serializer()), saved.getString("items"))
|
val rows = json.decodeFromString(ListSerializer(BaseItemDto.serializer()), saved.getString("items"))
|
||||||
val numbers = saved.getJSONArray("seasonNumbers").let { array -> List(array.length()) { array.getInt(it) } }
|
val numbers = saved.getJSONArray("seasonNumbers").let { array -> List(array.length()) { array.getInt(it) } }
|
||||||
build(rows, numbers, saved.optInt("selectedSeason"), saved.getString("watch"), saved.optString("target").takeIf { it.isNotEmpty() }, preferences)
|
build(rows, numbers, saved.optInt("selectedSeason"), saved.getString("watch"), saved.optString("target").takeIf { it.isNotEmpty() }, preferences)
|
||||||
.copy(savedAt = savedTime(file), trailerUrl = saved.optString("trailer").takeIf { it.isNotEmpty() }, watchReady = false)
|
.copy(savedAt = savedTime(file), trailerUrl = saved.optString("trailer").takeIf { it.isNotEmpty() })
|
||||||
} catch (_: java.io.IOException) { null } catch (_: IllegalArgumentException) { null } catch (_: org.json.JSONException) { null }
|
} catch (_: java.io.IOException) { null } catch (_: IllegalArgumentException) { null } catch (_: org.json.JSONException) { null }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -89,12 +87,12 @@ class TitleReadFacade @Inject constructor(
|
||||||
val series = if (opened.type == BaseItemKind.EPISODE && opened.seriesId != null) readItem(opened.seriesId!!) else opened
|
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 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 {
|
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 selectedNumber = season ?: next?.parentIndexNumber ?: seasons.firstOrNull()?.indexNumber
|
||||||
val selected = seasons.firstOrNull { it.indexNumber == selectedNumber } ?: seasons.firstOrNull()
|
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 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 }?.let { readItem(it.id) }
|
val target = next ?: episodes.firstOrNull { it.type == BaseItemKind.EPISODE }
|
||||||
val rows = (listOf(series, opened) + listOfNotNull(target) + episodes).distinctBy { it.id }
|
val rows = (listOf(series, opened) + listOfNotNull(target) + episodes).distinctBy { it.id }
|
||||||
val numbers = seasons.mapNotNull { it.indexNumber }
|
val numbers = seasons.mapNotNull { it.indexNumber }
|
||||||
val result = build(rows, numbers, selected?.indexNumber, opened.id.toString(), target?.id?.toString(), preferences)
|
val result = build(rows, numbers, selected?.indexNumber, opened.id.toString(), target?.id?.toString(), preferences)
|
||||||
|
|
@ -123,9 +121,8 @@ class TitleReadFacade @Inject constructor(
|
||||||
return item.copy(providerIds = metadata.providerIds ?: item.providerIds, people = item.people ?: metadata.people)
|
return item.copy(providerIds = metadata.providerIds ?: item.providerIds, people = item.people ?: metadata.people)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Series actions re-resolve NextUp; explicit episodes and Restart retain their exact identity. */
|
/** A failed reachability read leaves navigation on the title instead of opening a dead player. */
|
||||||
suspend fun preparePlayback(id: UUID): BaseItemDto? =
|
suspend fun preparePlayback(id: UUID) { api.userLibraryApi.getItem(id) }
|
||||||
readPlayback(api, repository.currentUser?.id, id)
|
|
||||||
|
|
||||||
/** Track selection uses the same repository consumed by the upstream player. */
|
/** Track selection uses the same repository consumed by the upstream player. */
|
||||||
suspend fun apply(action: TitleAction) = withContext(Dispatchers.IO) {
|
suspend fun apply(action: TitleAction) = withContext(Dispatchers.IO) {
|
||||||
|
|
@ -166,7 +163,7 @@ class TitleReadFacade @Inject constructor(
|
||||||
} else emptyList()
|
} else emptyList()
|
||||||
val person = previous.firstOrNull() ?: api.userLibraryApi.getItem(id).content
|
val person = previous.firstOrNull() ?: api.userLibraryApi.getItem(id).content
|
||||||
val credits = api.itemsApi.getItems(GetItemsRequest(personIds = listOf(id), recursive = true,
|
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()
|
sortBy = listOf(ItemSortBy.PRODUCTION_YEAR, ItemSortBy.SORT_NAME))).content.items.orEmpty()
|
||||||
val rows = listOf(person) + (previous.drop(1) + credits).distinctBy { it.id }
|
val rows = listOf(person) + (previous.drop(1) + credits).distinctBy { it.id }
|
||||||
if (personCacheFile(id) != file) throw kotlinx.coroutines.CancellationException("Performer identity changed")
|
if (personCacheFile(id) != file) throw kotlinx.coroutines.CancellationException("Performer identity changed")
|
||||||
|
|
@ -192,44 +189,27 @@ class TitleReadFacade @Inject constructor(
|
||||||
streams.filter { it.type == MediaStreamType.AUDIO }.map { TitleTrack(it.index, it.displayTitle ?: it.language ?: it.codec.orEmpty(), it.isDefault) },
|
streams.filter { it.type == MediaStreamType.AUDIO }.map { TitleTrack(it.index, it.displayTitle ?: it.language ?: it.codec.orEmpty(), it.isDefault) },
|
||||||
streams.filter { it.type == MediaStreamType.SUBTITLE }.map { TitleTrack(it.index, it.displayTitle ?: it.language ?: it.codec.orEmpty(), it.isDefault) })
|
streams.filter { it.type == MediaStreamType.SUBTITLE }.map { TitleTrack(it.index, it.displayTitle ?: it.language ?: it.codec.orEmpty(), it.isDefault) })
|
||||||
}
|
}
|
||||||
val pageItem = mapItem(item).copy(seasons = seasonNumbers.size)
|
return TitlePageData(mapItem(item).copy(seasons = seasonNumbers.size), target?.takeIf { it.type == BaseItemKind.EPISODE || (it.userData?.playbackPositionTicks ?: 0) > 0 }?.let { mapItem(it) },
|
||||||
return TitlePageData(pageItem, target?.takeIf { it.type == BaseItemKind.EPISODE || (it.userData?.playbackPositionTicks ?: 0) > 0 }?.let { mapItem(it, pageItem.backdrop) },
|
rows.first { it.id.toString() == watchId }.let { mapItem(it) }, versions, chosen?.source?.id, chosen?.audioStream?.stream?.index,
|
||||||
rows.first { it.id.toString() == watchId }.let { mapItem(it, pageItem.backdrop) }, versions, chosen?.source?.id, chosen?.audioStream?.stream?.index,
|
|
||||||
if (chosen?.subtitlesDisabled == true) -1 else chosen?.subtitleStream?.stream?.index ?: -1,
|
if (chosen?.subtitlesDisabled == true) -1 else chosen?.subtitleStream?.stream?.index ?: -1,
|
||||||
item.remoteTrailers.orEmpty().firstNotNullOfOrNull { it.url },
|
item.remoteTrailers.orEmpty().firstNotNullOfOrNull { it.url },
|
||||||
cast = item.people.orEmpty().filter { it.type.toString().equals("Actor", true) }.map { person ->
|
cast = item.people.orEmpty().filter { it.type.toString().equals("Actor", true) }.map { person ->
|
||||||
TitlePerson(person.id.toString(), person.name.orEmpty(), person.role.orEmpty(), person.primaryImageTag?.let { images.getItemImageUrl(person.id, ImageType.PRIMARY, maxWidth = 200, maxHeight = 200, tag = it) })
|
TitlePerson(person.id.toString(), person.name.orEmpty(), person.role.orEmpty(), person.primaryImageTag?.let { images.getItemImageUrl(person.id, ImageType.PRIMARY, maxWidth = 200, maxHeight = 200, tag = it) })
|
||||||
},
|
},
|
||||||
episodes = rows.filter { it.type == BaseItemKind.EPISODE && it.parentIndexNumber == selectedSeason }.map { mapItem(it, pageItem.backdrop) },
|
episodes = rows.filter { it.type == BaseItemKind.EPISODE && it.parentIndexNumber == selectedSeason }.map { mapItem(it) },
|
||||||
connection = TitleConnection.SignedOut, seasonNumbers = seasonNumbers, selectedSeason = selectedSeason)
|
connection = TitleConnection.SignedOut, seasonNumbers = seasonNumbers, selectedSeason = selectedSeason)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun mapItem(item: BaseItemDto, seriesBackdrop: String? = null): TitleItem = TitleItem(
|
private fun mapItem(item: BaseItemDto): TitleItem = TitleItem(
|
||||||
item.id.toString(), item.name.orEmpty(), when (item.type) { BaseItemKind.MOVIE -> "movie"; BaseItemKind.EPISODE -> "episode"; else -> "tv" },
|
item.id.toString(), item.name.orEmpty(), when (item.type) { BaseItemKind.MOVIE -> "movie"; BaseItemKind.EPISODE -> "episode"; else -> "tv" },
|
||||||
item.productionYear, (item.runTimeTicks ?: 0) / 10_000_000, overview = item.overview.orEmpty(), genres = item.genres.orEmpty(),
|
item.productionYear, (item.runTimeTicks ?: 0) / 10_000_000, overview = item.overview.orEmpty(), genres = item.genres.orEmpty(),
|
||||||
poster = item.imageTags?.get(ImageType.PRIMARY)?.let { images.getItemImageUrl(item.id, ImageType.PRIMARY, maxWidth = 256, maxHeight = 384, tag = it) },
|
poster = item.imageTags?.get(ImageType.PRIMARY)?.let { images.getItemImageUrl(item.id, ImageType.PRIMARY, maxWidth = 256, maxHeight = 384, tag = it) },
|
||||||
backdrop = if (item.type == BaseItemKind.EPISODE) episodeArtwork(item, seriesBackdrop) else item.backdropImageTags?.firstOrNull()?.let { images.getItemImageUrl(item.id, ImageType.BACKDROP, maxWidth = WolfskinTokens.Image.heroRequestWidth, maxHeight = WolfskinTokens.Image.heroRequestHeight, tag = it) },
|
backdrop = item.backdropImageTags?.firstOrNull()?.let { images.getItemImageUrl(item.id, ImageType.BACKDROP, maxWidth = 1280, maxHeight = 720, tag = it) },
|
||||||
held = true, watched = item.userData?.played == true, resumeSeconds = (item.userData?.playbackPositionTicks ?: 0) / 10_000_000,
|
held = true, watched = item.userData?.played == true, resumeSeconds = (item.userData?.playbackPositionTicks ?: 0) / 10_000_000,
|
||||||
seriesId = item.seriesId?.toString(), seriesName = item.seriesName, season = item.parentIndexNumber ?: 0, episode = item.indexNumber ?: 0,
|
seriesId = item.seriesId?.toString(), seriesName = item.seriesName, season = item.parentIndexNumber ?: 0, episode = item.indexNumber ?: 0,
|
||||||
tmdbId = item.providerIds?.entries?.firstOrNull { it.key.equals("Tmdb", true) }?.value?.toLongOrNull(),
|
tmdbId = item.providerIds?.entries?.firstOrNull { it.key.equals("Tmdb", true) }?.value?.toLongOrNull(),
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun episodeArtwork(item: BaseItemDto, seriesBackdrop: String?): String? {
|
|
||||||
val type = when {
|
|
||||||
item.imageTags?.get(ImageType.PRIMARY) != null -> ImageType.PRIMARY
|
|
||||||
item.imageTags?.get(ImageType.THUMB) != null -> ImageType.THUMB
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
if (type != null) return images.getItemImageUrl(item.id, type,
|
|
||||||
maxWidth = WolfskinTokens.Image.episodeRequestWidth, maxHeight = WolfskinTokens.Image.episodeRequestHeight, tag = item.imageTags?.get(type))
|
|
||||||
return seriesBackdrop ?: item.parentBackdropItemId?.let { parent ->
|
|
||||||
item.parentBackdropImageTags?.firstOrNull()?.let { tag ->
|
|
||||||
images.getItemImageUrl(parent, ImageType.BACKDROP,
|
|
||||||
maxWidth = WolfskinTokens.Image.episodeRequestWidth, maxHeight = WolfskinTokens.Image.episodeRequestHeight, tag = tag)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun cacheFile(id: UUID): File = File(context.filesDir, "wolfskin-title/${repository.currentServer?.id}/${repository.currentUser?.id}/$id.json")
|
private fun cacheFile(id: UUID): File = File(context.filesDir, "wolfskin-title/${repository.currentServer?.id}/${repository.currentUser?.id}/$id.json")
|
||||||
private fun personCacheFile(id: UUID): File = File(cacheFile(id).parentFile, "person-$id.json")
|
private fun personCacheFile(id: UUID): File = File(cacheFile(id).parentFile, "person-$id.json")
|
||||||
private fun savedTime(file: File): String = java.text.SimpleDateFormat("HH:mm", Locale.getDefault()).format(java.util.Date(file.lastModified()))
|
private fun savedTime(file: File): String = java.text.SimpleDateFormat("HH:mm", Locale.getDefault()).format(java.util.Date(file.lastModified()))
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,6 @@ class ConductarrClient(
|
||||||
timeouts: ClientTimeouts = ClientTimeouts(),
|
timeouts: ClientTimeouts = ClientTimeouts(),
|
||||||
private val nowMillis: () -> Long = System::currentTimeMillis,
|
private val nowMillis: () -> Long = System::currentTimeMillis,
|
||||||
private val onEvent: (ClientEvent) -> Unit = {},
|
private val onEvent: (ClientEvent) -> Unit = {},
|
||||||
dns: PersistentDns? = null,
|
|
||||||
) : Closeable {
|
) : Closeable {
|
||||||
private val base = baseUrl.toHttpUrl()
|
private val base = baseUrl.toHttpUrl()
|
||||||
private val scope: String
|
private val scope: String
|
||||||
|
|
@ -75,7 +74,6 @@ class ConductarrClient(
|
||||||
private val cacheIdentityLock = Any()
|
private val cacheIdentityLock = Any()
|
||||||
private val catalogueReads = Semaphore(2)
|
private val catalogueReads = Semaphore(2)
|
||||||
private val http = OkHttpClient.Builder()
|
private val http = OkHttpClient.Builder()
|
||||||
.apply { if (dns != null) DnsConnectionRetry.configure(this, dns) }
|
|
||||||
.connectTimeout(timeouts.connectSeconds, TimeUnit.SECONDS)
|
.connectTimeout(timeouts.connectSeconds, TimeUnit.SECONDS)
|
||||||
.readTimeout(timeouts.readSeconds, TimeUnit.SECONDS)
|
.readTimeout(timeouts.readSeconds, TimeUnit.SECONDS)
|
||||||
.callTimeout(timeouts.callSeconds, TimeUnit.SECONDS)
|
.callTimeout(timeouts.callSeconds, TimeUnit.SECONDS)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,10 +2,6 @@
|
||||||
package org.justinvisser.wolfskin.conductarr
|
package org.justinvisser.wolfskin.conductarr
|
||||||
|
|
||||||
import java.nio.file.Files
|
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.first
|
||||||
import kotlinx.coroutines.flow.toList
|
import kotlinx.coroutines.flow.toList
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
|
|
@ -18,24 +14,13 @@ fun main() = runBlocking {
|
||||||
try {
|
try {
|
||||||
MockWebServer().use { server ->
|
MockWebServer().use { server ->
|
||||||
server.start()
|
server.start()
|
||||||
val baseUrl = server.url("/").newBuilder().host("localhost").build().toString()
|
val baseUrl = server.url("/").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))
|
|
||||||
server.enqueue(MockResponse.Builder().body(PROFILES_JSON).addHeader("ETag", "\"smoke\"").build())
|
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
|
val live = client.read(TvRoutes.profiles()).toList().single() as ReadResult.Value
|
||||||
check(live.saved.value!!.profiles!!.single().name == "Justin")
|
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)).use { client ->
|
||||||
ConductarrClient(baseUrl, TEST_TOKEN, FileCacheStore(directory), dns = reopened).use { client ->
|
|
||||||
val saved = client.read(TvRoutes.profiles(), ReadTrigger.CACHE_ONLY).first() as ReadResult.Value
|
val saved = client.read(TvRoutes.profiles(), ReadTrigger.CACHE_ONLY).first() as ReadResult.Value
|
||||||
check(saved.saved.fromDisk)
|
check(saved.saved.fromDisk)
|
||||||
server.enqueue(MockResponse.Builder().code(304).build())
|
server.enqueue(MockResponse.Builder().code(304).build())
|
||||||
|
|
|
||||||
|
|
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,115 +0,0 @@
|
||||||
# Wolfskin TV specification — revision 6
|
|
||||||
|
|
||||||
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/`.
|
|
||||||
|
|
||||||
## Who’s watching
|
|
||||||
|
|
||||||
The Settings gear is horizontally centred beneath the complete people row, independent of person count or selection. Justin confirmed this placement. It uses the same 4 px accent straddle ring as the people, with the existing 1.08 scale and focus-only Settings caption. Down still reaches it only from the final person; Up returns to that person.
|
|
||||||
|
|
||||||
## Focus visibility
|
|
||||||
|
|
||||||
“Centered” means consistent visibility, not geometric centering. Keep the approved shelf rule: one-card lookahead, shifting after the second-last visible slot, with first/last positions clamped to the actual contents. Short rows do not manufacture empty scroll space. Poster, tile, wide, service, episode, cast and related shelves follow the same rule.
|
|
||||||
|
|
||||||
The focused card and its ring remain fully inside the viewport, with at least the Level 2 gap (40 px) of horizontal clearance at each screen edge. Focused rows clear the nav band and the bottom edge. Preserve the approved 168 px heading pin and initial fully-visible-row exceptions; hero/header anchors remain zero. First entry, row changes, row ends and Back from a title must reconcile retained logical selection with the actual scroll position. A frame is recorded as positioned only after its scroll finishes. Discover uses the approved six poster slots, not a rounded-down five-slot approximation.
|
|
||||||
|
|
||||||
Library and Person grids retain six columns and the existing Level 2 row spacing. The final Person column keeps the full poster width; horizontal padding must not shrink it.
|
|
||||||
|
|
||||||
## Episode artwork and dimensions
|
|
||||||
|
|
||||||
Episode cards use **400×280 px (200×140 dp)** art: 55 px / 24.4% taller than the previous 400×225 px tile. Other tile and wide-card sizes are unchanged. The episode shelf is `84 + 280 + 68 = 432 px`; its heading band and 68 px clearance before Cast remain Level 2. Cast moves down by the additional card height rather than overlapping it.
|
|
||||||
|
|
||||||
Choose available art in this order:
|
|
||||||
|
|
||||||
1. Episode `Primary` image — Jellyfin stores episode stills under this type.
|
|
||||||
2. Episode `Thumb` image.
|
|
||||||
3. Series `Backdrop`, centre-cropped to the episode slot.
|
|
||||||
4. The existing missing-art treatment when none is available.
|
|
||||||
|
|
||||||
A series-page read supplies its already-read backdrop to the episode projection; it does not make one series request per episode. Saved pages rebuild the same projection from cached metadata. Thumbnail reads and prefetch use the 400×280 px episode request, not the unrelated poster dimensions.
|
|
||||||
|
|
||||||
Read-only Jellyfin evidence: all ten The Bear season-three episodes have Primary tags and no episode Backdrop tags. Tomorrow’s Primary source is 2100×1182; the series Backdrop is 3840×2160. These are existing aired files, not missing/unaired episode placeholders.
|
|
||||||
|
|
||||||
## Episode summaries and series header spacing
|
|
||||||
|
|
||||||
In episode context, the summary belongs to the episode: focused episode first, explicitly opened episode next, otherwise the Up-next episode. Use the show overview only when no episode context is selected. An absent episode overview stays absent instead of substituting the show's description. The focused episode card can show two ellipsised lines of its own overview below its episode label.
|
|
||||||
|
|
||||||
A held series header measures its actual copy, actions and action-caption band, then leaves **40 px** before the Seasons row. Remove the unconditional 900 px series-header spacer. Films, non-held titles and loading/error layouts retain their approved fixed header geometry. Measured series geometry also drives the frame offsets and focus-follow calculation, preventing season/episode rows from scrolling to stale offsets. Long titles or summaries may grow the header; controls must never overlap the seasons.
|
|
||||||
|
|
||||||
## Hero image quality and memory
|
|
||||||
|
|
||||||
| Surface | Requested/decode bound | Format policy |
|
|
||||||
|---|---|---|
|
|
||||||
| Home and Discover hero; title backdrop | 1920×1080 | ARGB, 7.91 MiB at the bound |
|
|
||||||
| Upright hero poster | 420×630 | RGB565 where supported, 0.50 MiB |
|
|
||||||
| Episode card | 400×280 | ARGB, 0.43 MiB |
|
|
||||||
| Library poster, ordinary tile/wide, cast | Existing slot dimensions | Existing RGB565/ARGB policy |
|
|
||||||
|
|
||||||
Jellyfin image URLs discard conflicting width/height/max/fill caps and request the consuming slot while preserving image tags, quality and other query parameters. Jellyfin quality remains **96**. TMDB heroes use the original source rather than the previous 780 px source; Coil constrains decoding to the panel. Upright TMDB sheets use a suitable poster tier. Intentionally blurred poster-only washes remain bounded to the upright source, not an oversized portrait bitmap. Decode fitting prevents allocation of an oversized off-ratio source merely to fill a crop.
|
|
||||||
|
|
||||||
The shared memory cache remains **64 MiB**, disk cache **128 MiB**, image concurrency **4**. Three 1080p hero bitmaps consume 23.73 MiB, leaving 40.27 MiB of the cache for other art. These are allocation bounds, not measured TV resident-memory claims; do not request or decode 4K hero bitmaps on the 2 GB armv7 box.
|
|
||||||
|
|
||||||
Read-only source measurement: Desperate Housewives has a 2732×1536 Backdrop. With quality 96, Jellyfin returned a 1281×720 JPEG of 438,329 bytes for the old fill request and a 1921×1080 JPEG of 982,435 bytes for the new request; the extra source pixel is Jellyfin aspect-ratio rounding. The display decode stays bounded to 1920×1080.
|
|
||||||
|
|
||||||
## Discover lane spacing
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
{
|
|
||||||
"specRevision": 5,
|
|
||||||
"base": "data/tv-v2/focus-2026-11-06/index.html",
|
|
||||||
"baseSha256": "1bb7f4097a5303a58e45ec5e61cf867fa6cfdcbfe12fc7a196f00acacae5e48d",
|
|
||||||
"stagePx": [1920, 1080],
|
|
||||||
"density": 2,
|
|
||||||
"picker": {
|
|
||||||
"gearAlignment": "center of the complete people row",
|
|
||||||
"gearRing": "same accent straddle ring as picker people",
|
|
||||||
"gearFocusScale": 1.08,
|
|
||||||
"navigation": "unchanged: Down from the final person only"
|
|
||||||
},
|
|
||||||
"focus": {
|
|
||||||
"horizontalRule": "second-last-slot lookahead; clamp first/last to real contents",
|
|
||||||
"minimumScreenSideMarginPx": 40,
|
|
||||||
"headingPinPx": 168,
|
|
||||||
"geometricCentering": false,
|
|
||||||
"discoverPosterSlots": 6
|
|
||||||
},
|
|
||||||
"episode": {
|
|
||||||
"artPx": [400, 280],
|
|
||||||
"shelfHeightPx": 432,
|
|
||||||
"gapBeforeCastPx": 68,
|
|
||||||
"artFallback": ["episode Primary", "episode Thumb", "series Backdrop crop", "missing art"],
|
|
||||||
"summaryContext": ["focused episode", "explicitly opened episode", "up-next episode", "show when no episode context"],
|
|
||||||
"focusedCardSummaryLines": 2
|
|
||||||
},
|
|
||||||
"seriesHeader": {
|
|
||||||
"height": "measured copy, actions and caption band plus gap",
|
|
||||||
"gapBeforeSeasonsPx": 40,
|
|
||||||
"otherTitleHeaders": "unchanged"
|
|
||||||
},
|
|
||||||
"discover": {
|
|
||||||
"allLaneHeadingBandPx": 104,
|
|
||||||
"allLaneHeadingToCardGapPx": 60,
|
|
||||||
"posterShelfHeightPx": 652,
|
|
||||||
"serviceShelfHeightPx": 316,
|
|
||||||
"homeHeadingBand": "unchanged"
|
|
||||||
},
|
|
||||||
"images": {
|
|
||||||
"heroDecodeBoundPx": [1920, 1080],
|
|
||||||
"heroPosterDecodeBoundPx": [420, 630],
|
|
||||||
"jellyfinQuality": 96,
|
|
||||||
"memoryCacheMiB": 64,
|
|
||||||
"diskCacheMiB": 128,
|
|
||||||
"concurrentReads": 4
|
|
||||||
},
|
|
||||||
"nativeFrames": "https://pages.justinvisser.org/wolfskin/tv-fb1-visual/",
|
|
||||||
"specification": "../../tv-spec/spec.md"
|
|
||||||
}
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>Wolfskin TV · feedback delta · revision 5</title>
|
|
||||||
<style>
|
|
||||||
:root { color-scheme: dark; font: 16px/1.5 system-ui, sans-serif; }
|
|
||||||
body { max-width: 1440px; margin: 32px auto; padding: 0 24px; background: #07070a; color: #f4f4f6; }
|
|
||||||
a { color: #cfb0f6; } nav { display: flex; flex-wrap: wrap; gap: 24px; }
|
|
||||||
section { margin: 48px 0; } h2 { margin-bottom: 8px; }
|
|
||||||
img { display: block; width: 100%; height: auto; margin: 16px 0; border: 1px solid #33333b; }
|
|
||||||
summary { cursor: pointer; } .note { color: #b9b9c1; }
|
|
||||||
</style>
|
|
||||||
<h1>Wolfskin TV · feedback delta</h1>
|
|
||||||
<p>Specification revision 5. The approved frames remain the baseline; these native 1920×1080 frames show only the requested feedback changes. This is a frame prototype, not a simulated Jellyfin client.</p>
|
|
||||||
<nav><a href="#picker">Who’s watching</a><a href="#home">Home</a><a href="#discover">Discover</a><a href="#series">Series</a><a href="#episodes">Episodes</a><a href="#person">Person</a><a href="delta.json">Exact delta values</a></nav>
|
|
||||||
<section id="picker"><h2>Who’s watching</h2><p>Gear centred beneath everyone, with the person-style focus ring.</p>
|
|
||||||
<img width="1920" height="1080" alt="Native picker after: Settings centred below the complete people row" src="https://pages.justinvisser.org/wolfskin/tv-fb1-visual/after/picker.png">
|
|
||||||
<details><summary>Before</summary><img loading="lazy" width="1920" height="1080" alt="Native picker before: gear below the final person" src="https://pages.justinvisser.org/wolfskin/tv-fb1-visual/before/picker.png"></details></section>
|
|
||||||
<section id="home"><h2>Home</h2><p>Approved edge scrolling retained. Short/long poster, tile and wide rows, row changes and native title Back exercise the same visibility contract.</p>
|
|
||||||
<img loading="lazy" width="1920" height="1080" alt="Native Home after: focused card safely inside a long shelf" src="https://pages.justinvisser.org/wolfskin/tv-fb1-visual/after/home.png">
|
|
||||||
<details><summary>Before</summary><img loading="lazy" width="1920" height="1080" alt="Native Home baseline" src="https://pages.justinvisser.org/wolfskin/tv-fb1-visual/before/home.png"></details>
|
|
||||||
<details><summary>Real-source hero request comparison</summary><p class="note">Same native renderer and source artwork: old 720p response, then panel-sized response. Intentionally blurred poster washes are unchanged.</p><img loading="lazy" width="1920" height="1080" alt="Native hero using the old 720p source response" src="https://pages.justinvisser.org/wolfskin/tv-fb1-visual/before/home-hero.png"><img loading="lazy" width="1920" height="1080" alt="Native hero using the panel-resolution source response" src="https://pages.justinvisser.org/wolfskin/tv-fb1-visual/after/home-hero.png"></details></section>
|
|
||||||
<section id="discover"><h2>Discover</h2><p>60 px from lane title to cards; all lanes use the same 104 px heading band.</p>
|
|
||||||
<img loading="lazy" width="1920" height="1080" alt="Native Discover after: more space above every lane's cards" src="https://pages.justinvisser.org/wolfskin/tv-fb1-visual/after/discover.png">
|
|
||||||
<details><summary>Before</summary><img loading="lazy" width="1920" height="1080" alt="Native Discover before: smaller heading gap" src="https://pages.justinvisser.org/wolfskin/tv-fb1-visual/before/discover.png"></details></section>
|
|
||||||
<section id="series"><h2>Series header</h2><p>Episode-context summary and a measured header instead of the empty fixed-height spacer. Season chips and thumbnail artwork enter the initial view.</p>
|
|
||||||
<img loading="lazy" width="1920" height="1080" alt="Native Bear series header after: episode overview and visible thumbnail row" src="https://pages.justinvisser.org/wolfskin/tv-fb1-visual/after/series.png">
|
|
||||||
<details><summary>Before</summary><img loading="lazy" width="1920" height="1080" alt="Native Bear series header before: show overview and empty space before seasons" src="https://pages.justinvisser.org/wolfskin/tv-fb1-visual/before/series.png"></details></section>
|
|
||||||
<section id="episodes"><h2>Episode shelf</h2><p>Real episode Primary artwork, 400×280 px cards and the focused episode's own overview. Cast retains its Level 2 clearance.</p>
|
|
||||||
<img loading="lazy" width="1920" height="1080" alt="Native Bear episodes after: taller thumbnail cards, focused overview and Cast below" src="https://pages.justinvisser.org/wolfskin/tv-fb1-visual/after/episodes.png">
|
|
||||||
<details><summary>Before</summary><img loading="lazy" width="1920" height="1080" alt="Native Bear episodes before: monitor placeholders" src="https://pages.justinvisser.org/wolfskin/tv-fb1-visual/before/episodes.png"></details></section>
|
|
||||||
<section id="person"><h2>Person</h2><p>Six full-width poster columns; no shrinking of the final column.</p>
|
|
||||||
<img loading="lazy" width="1920" height="1080" alt="Native Person after" src="https://pages.justinvisser.org/wolfskin/tv-fb1-visual/after/person.png">
|
|
||||||
<details><summary>Before</summary><img loading="lazy" width="1920" height="1080" alt="Native Person before" src="https://pages.justinvisser.org/wolfskin/tv-fb1-visual/before/person.png"></details></section>
|
|
||||||
</html>
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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 >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))
|
|
||||||
|
|
@ -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"
|
|
||||||
}
|
|
||||||
|
|
@ -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 >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>
|
|
||||||
|
|
@ -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}
|
|
||||||
|
|
@ -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';
|
|
||||||
})();
|
|
||||||
|
Before Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 244 B |
|
Before Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 204 B |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 388 B |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 522 B |
|
Before Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 4 KiB |