Compare commits
31 commits
fm/tv-spac
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 332fc6476d | |||
| d18a05bdb0 | |||
| fd48426fa7 | |||
| 052d0b8898 | |||
| acb72335e3 | |||
| 53a2cff2c8 | |||
| 327f631f0d | |||
| e340d470c1 | |||
| f4fb6e22c9 | |||
| 5bd2affc1a | |||
| 8e40ac0869 | |||
| 54c23e076a | |||
| 12fd12f754 | |||
| 7b88f9b54b | |||
| 5162c299db | |||
| 9d2471c5b1 | |||
| 278335f009 | |||
| deb24e3c9f | |||
| 556d633e88 | |||
| 34087d8eb5 | |||
| 33dc65bf18 | |||
| ef3ae3f21b | |||
| d0e38306b3 | |||
| 5306f8ddc4 | |||
| 59f4a06815 | |||
| 427b18cdc5 | |||
| 93a41ea2d3 | |||
| 1c21f50acb | |||
| e8d0c52088 | |||
| db2541feff | |||
| f37ad0f0fe |
120 changed files with 14315 additions and 438 deletions
|
|
@ -133,6 +133,13 @@ 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 {
|
||||||
|
|
@ -199,6 +206,10 @@ 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 {
|
||||||
|
|
@ -228,6 +239,11 @@ 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()
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ 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
|
||||||
|
|
@ -576,12 +577,22 @@ class MainActivityViewModel
|
||||||
val restoredSession =
|
val restoredSession =
|
||||||
if (signInAutomatically) {
|
if (signInAutomatically) {
|
||||||
Timber.v("Sign in automatically")
|
Timber.v("Sign in automatically")
|
||||||
serverRepository.restoreLastSession()
|
serverRepository.restoreSavedSession()
|
||||||
} 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,6 +17,7 @@ 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)
|
||||||
|
|
@ -54,6 +55,7 @@ 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
|
||||||
|
|
@ -110,6 +112,9 @@ 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,6 +51,10 @@ 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,6 +8,8 @@ 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
|
||||||
|
|
@ -101,15 +103,7 @@ 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 userDto by apiClient.userApi.getCurrentUser()
|
val (updatedServer, userDto) = readSession(server)
|
||||||
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,
|
||||||
|
|
@ -125,6 +119,45 @@ 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,6 +24,13 @@ 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,6 +26,9 @@ 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
|
||||||
|
|
||||||
|
|
@ -91,12 +94,14 @@ object AppModule {
|
||||||
@StandardOkHttpClient
|
@StandardOkHttpClient
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun okHttpClient() =
|
fun okHttpClient(dns: PersistentDns) =
|
||||||
OkHttpClient
|
DnsConnectionRetry.configure(OkHttpClient.Builder(), dns).build()
|
||||||
.Builder()
|
|
||||||
.apply {
|
/** Resolve noBackupFilesDir lazily: Android may create it and lookup always runs off main. */
|
||||||
// TODO user agent, timeouts, logging, etc
|
@Provides
|
||||||
}.build()
|
@Singleton
|
||||||
|
fun dns(@ApplicationContext context: Context): PersistentDns =
|
||||||
|
PersistentDns(file = { File(context.noBackupFilesDir, "wolfskin-dns.json") })
|
||||||
|
|
||||||
@AuthOkHttpClient
|
@AuthOkHttpClient
|
||||||
@Provides
|
@Provides
|
||||||
|
|
|
||||||
|
|
@ -82,9 +82,11 @@ 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()
|
||||||
|
|
@ -358,6 +360,7 @@ class PlayExternalViewModel
|
||||||
failed = false,
|
failed = false,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
watchScope?.let { com.github.damontecres.wholphin.util.PlaybackWatchState.stopped(context, it) }
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Timber.w(
|
Timber.w(
|
||||||
|
|
|
||||||
|
|
@ -830,10 +830,12 @@ 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)
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,11 @@ 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
|
||||||
|
|
@ -73,10 +78,7 @@ 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 =
|
val okHttpClient = EntryPointAccessors.fromApplication(context.applicationContext, CrashNetwork::class.java).client()
|
||||||
OkHttpClient
|
|
||||||
.Builder()
|
|
||||||
.build()
|
|
||||||
val okHttpFactory = OkHttpFactory(okHttpClient)
|
val okHttpFactory = OkHttpFactory(okHttpClient)
|
||||||
val api =
|
val api =
|
||||||
createJellyfin {
|
createJellyfin {
|
||||||
|
|
@ -89,3 +91,11 @@ 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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
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,6 +7,8 @@ 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
|
||||||
|
|
@ -31,9 +33,12 @@ 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() {
|
||||||
|
|
@ -70,19 +75,23 @@ 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() {
|
||||||
// player.removeListener(this)
|
if (released) return
|
||||||
|
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") {
|
||||||
getState.invoke()?.let { state ->
|
stoppedState?.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)
|
||||||
|
|
@ -96,6 +105,7 @@ class TrackActivityPlaybackListener(
|
||||||
liveStreamId = state.liveStreamId,
|
liveStreamId = state.liveStreamId,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
onStopped()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -117,6 +127,7 @@ 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) {
|
||||||
|
|
@ -152,7 +163,7 @@ class TrackActivityPlaybackListener(
|
||||||
) {
|
) {
|
||||||
coroutineScope.launchIO {
|
coroutineScope.launchIO {
|
||||||
try {
|
try {
|
||||||
block.invoke(this)
|
reports.withLock { block.invoke(this) }
|
||||||
} catch (ex: Exception) {
|
} catch (ex: Exception) {
|
||||||
Timber.w(ex, "Exception during %s", name)
|
Timber.w(ex, "Exception during %s", name)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
8
app/src/optimized/baselineProfiles/baseline-prof.txt
Normal file
8
app/src/optimized/baselineProfiles/baseline-prof.txt
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
# 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;
|
||||||
4
app/src/optimized/res/values/strings.xml
Normal file
4
app/src/optimized/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name" translatable="false">Wolfskin (Opt)</string>
|
||||||
|
</resources>
|
||||||
|
|
@ -32,6 +32,8 @@ 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
|
||||||
|
|
@ -116,9 +118,6 @@ 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()
|
||||||
|
|
||||||
|
|
@ -132,7 +131,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.restoreLastSession() }
|
coVerify(exactly = 0) { serverRepository.restoreSavedSession() }
|
||||||
coVerify(exactly = 0) { serverRepository.getMostRecentServer() }
|
coVerify(exactly = 0) { serverRepository.getMostRecentServer() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -150,7 +149,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.restoreLastSession() }
|
coVerify(exactly = 0) { serverRepository.restoreSavedSession() }
|
||||||
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)) }
|
||||||
|
|
@ -165,6 +164,20 @@ 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 {
|
||||||
|
|
@ -178,7 +191,7 @@ class TestMainActivityViewModel {
|
||||||
coEvery {
|
coEvery {
|
||||||
serverRepository.tryChangeUser(serverId, userId)
|
serverRepository.tryChangeUser(serverId, userId)
|
||||||
} returns null
|
} returns null
|
||||||
coEvery { serverRepository.restoreLastSession() } returns
|
coEvery { serverRepository.restoreSavedSession() } returns
|
||||||
RestoredSession.ServerOnly(server)
|
RestoredSession.ServerOnly(server)
|
||||||
|
|
||||||
viewModel.handleAppStart()
|
viewModel.handleAppStart()
|
||||||
|
|
@ -204,7 +217,7 @@ class TestMainActivityViewModel {
|
||||||
signInAutomatically = true
|
signInAutomatically = true
|
||||||
}
|
}
|
||||||
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null)
|
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null)
|
||||||
coEvery { serverRepository.restoreLastSession() } returns
|
coEvery { serverRepository.restoreSavedSession() } returns
|
||||||
RestoredSession.ServerOnly(server)
|
RestoredSession.ServerOnly(server)
|
||||||
coEvery {
|
coEvery {
|
||||||
serverRepository.tryChangeUser(serverId, userId)
|
serverRepository.tryChangeUser(serverId, userId)
|
||||||
|
|
@ -212,7 +225,7 @@ class TestMainActivityViewModel {
|
||||||
|
|
||||||
viewModel.handleAppStart()
|
viewModel.handleAppStart()
|
||||||
|
|
||||||
coVerify(exactly = 1) { serverRepository.restoreLastSession() }
|
coVerify(exactly = 1) { serverRepository.restoreSavedSession() }
|
||||||
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 ->
|
||||||
|
|
@ -232,11 +245,11 @@ class TestMainActivityViewModel {
|
||||||
signInAutomatically = true
|
signInAutomatically = true
|
||||||
}
|
}
|
||||||
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null)
|
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null)
|
||||||
coEvery { serverRepository.restoreLastSession() } returns RestoredSession.None
|
coEvery { serverRepository.restoreSavedSession() } returns RestoredSession.None
|
||||||
|
|
||||||
viewModel.handleAppStart()
|
viewModel.handleAppStart()
|
||||||
|
|
||||||
coVerify(exactly = 1) { serverRepository.restoreLastSession() }
|
coVerify(exactly = 1) { serverRepository.restoreSavedSession() }
|
||||||
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 ->
|
||||||
|
|
@ -254,7 +267,7 @@ class TestMainActivityViewModel {
|
||||||
signInAutomatically = true
|
signInAutomatically = true
|
||||||
}
|
}
|
||||||
every { serverRepository.current } returns MutableStateFlow(currentUser)
|
every { serverRepository.current } returns MutableStateFlow(currentUser)
|
||||||
coEvery { serverRepository.restoreLastSession() } returns
|
coEvery { serverRepository.restoreSavedSession() } returns
|
||||||
RestoredSession.Success(currentUser)
|
RestoredSession.Success(currentUser)
|
||||||
coEvery { intentService.parseIntent(any()) } returns IntentResult.NoOp
|
coEvery { intentService.parseIntent(any()) } returns IntentResult.NoOp
|
||||||
|
|
||||||
|
|
@ -271,7 +284,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.restoreLastSession() }
|
coVerify(exactly = 0) { serverRepository.restoreSavedSession() }
|
||||||
coVerify(exactly = 0) { serverRepository.getMostRecentServer() }
|
coVerify(exactly = 0) { serverRepository.getMostRecentServer() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,136 @@
|
||||||
|
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,5 +1,6 @@
|
||||||
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
|
||||||
|
|
@ -18,6 +19,12 @@ 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,6 +62,7 @@ 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
|
||||||
|
|
@ -91,12 +92,11 @@ object TestModule {
|
||||||
@StandardOkHttpClient
|
@StandardOkHttpClient
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun okHttpClient() =
|
fun okHttpClient(dns: PersistentDns) = AppModule.okHttpClient(dns)
|
||||||
OkHttpClient
|
|
||||||
.Builder()
|
@Provides
|
||||||
.apply {
|
@Singleton
|
||||||
// TODO user agent, timeouts, logging, etc
|
fun dns(@ApplicationContext context: Context): PersistentDns = AppModule.dns(context)
|
||||||
}.build()
|
|
||||||
|
|
||||||
@AuthOkHttpClient
|
@AuthOkHttpClient
|
||||||
@Provides
|
@Provides
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,7 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
// 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,8 +52,9 @@ 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.read(user.id) } returns HomeLibrary(resume = listOf(
|
coEvery { home.readWatch(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,7 +41,6 @@ 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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
// 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() }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,7 @@ import mockwebserver3.RecordedRequest
|
||||||
|
|
||||||
internal const val SHARED_TOKEN = "0123456789abcdef0123456789abcdef"
|
internal const val SHARED_TOKEN = "0123456789abcdef0123456789abcdef"
|
||||||
internal const val MINDEL_TOKEN = "fedcba9876543210fedcba9876543210"
|
internal const val MINDEL_TOKEN = "fedcba9876543210fedcba9876543210"
|
||||||
|
internal const val JUSTIN_TOKEN = "11111111111111111111111111111111"
|
||||||
|
|
||||||
/** A loopback server supplies wire JSON, never app projections or live service state. */
|
/** A loopback server supplies wire JSON, never app projections or live service state. */
|
||||||
internal class FakeConductarr : Closeable {
|
internal class FakeConductarr : Closeable {
|
||||||
|
|
@ -28,6 +29,8 @@ 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=")
|
||||||
|
|
||||||
|
|
@ -68,6 +71,8 @@ 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
|
||||||
|
|
@ -75,6 +80,9 @@ internal class FakeConductarr : Closeable {
|
||||||
if (key == "operator" && request.headers["Authorization"]?.contains(MINDEL_TOKEN) == true) {
|
if (key == "operator" && request.headers["Authorization"]?.contains(MINDEL_TOKEN) == true) {
|
||||||
body = JsonObject(body + mapOf("name" to JsonPrimitive("Mindel"), "requester" to JsonPrimitive("mindel"), "subject" to JsonPrimitive("jellyfin:mindel"), "watch_user" to JsonPrimitive("mindel")))
|
body = JsonObject(body + mapOf("name" to JsonPrimitive("Mindel"), "requester" to JsonPrimitive("mindel"), "subject" to JsonPrimitive("jellyfin:mindel"), "watch_user" to JsonPrimitive("mindel")))
|
||||||
}
|
}
|
||||||
|
if (key == "operator" && request.headers["Authorization"]?.contains(JUSTIN_TOKEN) == true) {
|
||||||
|
body = JsonObject(body + mapOf("name" to JsonPrimitive("Justin"), "requester" to JsonPrimitive("justin"), "subject" to JsonPrimitive("jellyfin:justin"), "watch_user" to JsonPrimitive("justin")))
|
||||||
|
}
|
||||||
if (key in setOf("options", "discover") && request.url.queryParameter("media_type") == "tv") {
|
if (key in setOf("options", "discover") && request.url.queryParameter("media_type") == "tv") {
|
||||||
body = JsonObject(body + ("media_type" to JsonPrimitive("tv")))
|
body = JsonObject(body + ("media_type" to JsonPrimitive("tv")))
|
||||||
if (key == "discover") body = JsonObject(body + ("items" to JsonArray(listOf(JsonObject(body.getValue("items").jsonArray.first().jsonObject + mapOf("media_type" to JsonPrimitive("tv"), "tmdb_id" to JsonPrimitive(44), "title" to JsonPrimitive("Dark")))))))
|
if (key == "discover") body = JsonObject(body + ("items" to JsonArray(listOf(JsonObject(body.getValue("items").jsonArray.first().jsonObject + mapOf("media_type" to JsonPrimitive("tv"), "tmdb_id" to JsonPrimitive(44), "title" to JsonPrimitive("Dark")))))))
|
||||||
|
|
|
||||||
|
|
@ -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.SORT_NAME, ItemFields.DATE_CREATED),
|
includeItemTypes = listOf(kind), fields = listOf(ItemFields.PROVIDER_IDS, 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,11 +43,13 @@ 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.read(personA.id) } coAnswers { started.complete(Unit); response.await() }
|
coEvery { facade.readWatch(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)
|
||||||
|
|
@ -62,8 +64,9 @@ 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.read(personA.id) } coAnswers { startedA.complete(Unit); responseA.await() }
|
coEvery { facade.readWatch(personA.id) } coAnswers { startedA.complete(Unit); responseA.await() }
|
||||||
coEvery { facade.read(personB.id) } throws IOException("fixture offline")
|
coEvery { facade.readWatch(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()
|
||||||
|
|
@ -80,7 +83,8 @@ class HomeRecoveryTest {
|
||||||
|
|
||||||
@Test fun readableJellyfinDoesNotBecomeAnOutageWhenLocalCacheCannotBeWritten() = runBlocking {
|
@Test fun readableJellyfinDoesNotBecomeAnOutageWhenLocalCacheCannotBeWritten() = runBlocking {
|
||||||
val facade = mockk<HomeReadFacade>()
|
val facade = mockk<HomeReadFacade>()
|
||||||
coEvery { facade.read(personA.id) } returns library
|
coEvery { facade.readWatch(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")
|
||||||
|
|
@ -93,7 +97,8 @@ class HomeRecoveryTest {
|
||||||
|
|
||||||
@Test fun renamedHouseholdProfileKeepsItsOwnJellyfinLibraryWithoutAConductarrIdentity() = runBlocking {
|
@Test fun renamedHouseholdProfileKeepsItsOwnJellyfinLibraryWithoutAConductarrIdentity() = runBlocking {
|
||||||
val facade = mockk<HomeReadFacade>()
|
val facade = mockk<HomeReadFacade>()
|
||||||
coEvery { facade.read(personA.id) } returns library
|
coEvery { facade.readWatch(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.SORT_NAME, ItemFields.DATE_CREATED),
|
fields = listOf(ItemFields.PROVIDER_IDS, 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,6 +48,21 @@ 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")
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,159 @@
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,253 @@
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.os.Looper
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
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.services.SetupDestination
|
||||||
|
import com.github.damontecres.wholphin.services.SetupNavigationManager
|
||||||
|
import io.mockk.*
|
||||||
|
import java.util.UUID
|
||||||
|
import java.util.concurrent.CopyOnWriteArrayList
|
||||||
|
import java.util.concurrent.CountDownLatch
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.coroutines.flow.last
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.test.resetMain
|
||||||
|
import kotlinx.coroutines.test.setMain
|
||||||
|
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.*
|
||||||
|
import org.justinvisser.wolfskin.design.ShellKey
|
||||||
|
import org.justinvisser.wolfskin.tv.read.LibraryReadFacade
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
/** Picker changes invalidate identity immediately without closing sockets on the UI thread. */
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class)
|
||||||
|
class PersonSwitchThreadingTest {
|
||||||
|
private val context = ApplicationProvider.getApplicationContext<Application>()
|
||||||
|
private lateinit var previousUrl: String
|
||||||
|
private var previousFixtures = false
|
||||||
|
|
||||||
|
@Before fun setUp() {
|
||||||
|
Dispatchers.setMain(Dispatchers.Unconfined)
|
||||||
|
ConductarrPreference.initialize(context)
|
||||||
|
previousUrl = ConductarrPreference.baseUrl
|
||||||
|
previousFixtures = ConductarrPreference.fixtures
|
||||||
|
ConductarrPreference.updateFixtures(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After fun tearDown() {
|
||||||
|
ConductarrPreference.updateBaseUrl(previousUrl)
|
||||||
|
ConductarrPreference.updateFixtures(previousFixtures)
|
||||||
|
Dispatchers.resetMain()
|
||||||
|
unmockkConstructor(ConductarrClient::class)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun pickerSwitchesKeepPersonDataAndDisposeEveryClientOffMainWithoutWaiting() = runBlocking {
|
||||||
|
assertSame(Looper.getMainLooper(), Looper.myLooper())
|
||||||
|
val closeThreads = CopyOnWriteArrayList<Boolean>()
|
||||||
|
val releaseCloses = CountDownLatch(1)
|
||||||
|
val closed = CountDownLatch(4)
|
||||||
|
val disposalErrors = CopyOnWriteArrayList<Throwable>()
|
||||||
|
val disposalScope = CoroutineScope(SupervisorJob() + Dispatchers.IO + CoroutineExceptionHandler { _, error -> disposalErrors.add(error) })
|
||||||
|
val failFirstClose = java.util.concurrent.atomic.AtomicBoolean(true)
|
||||||
|
mockkConstructor(ConductarrClient::class)
|
||||||
|
every { anyConstructed<ConductarrClient>().close() } answers {
|
||||||
|
val onMain = Looper.myLooper() == Looper.getMainLooper()
|
||||||
|
closeThreads.add(onMain)
|
||||||
|
check(!onMain) { "StrictMode: Conductarr socket close on main" }
|
||||||
|
check(releaseCloses.await(10, TimeUnit.SECONDS)) { "Picker waited for socket disposal" }
|
||||||
|
callOriginal()
|
||||||
|
closed.countDown()
|
||||||
|
if (failFirstClose.getAndSet(false)) throw java.io.IOException("Socket disposal fixture failure")
|
||||||
|
}
|
||||||
|
FakeConductarr().use { fake ->
|
||||||
|
ConductarrPreference.updateBaseUrl(fake.url)
|
||||||
|
val conductarr = ConductarrSession(MemoryCacheStore(), disposalScope)
|
||||||
|
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null)
|
||||||
|
val justin = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = JUSTIN_TOKEN)
|
||||||
|
val users = listOf(justin, justin.copy(id = UUID.randomUUID(), name = "Mindel", accessToken = MINDEL_TOKEN),
|
||||||
|
justin.copy(id = UUID.randomUUID(), name = "Shared", accessToken = SHARED_TOKEN))
|
||||||
|
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)
|
||||||
|
val libraries = mockk<LibraryReadFacade>(relaxed = true)
|
||||||
|
coEvery { libraries.readDestinations(any()) } returns emptyMap()
|
||||||
|
val session = WolfskinSession()
|
||||||
|
val model = WolfskinShellViewModel(context, session, repository, mockk(relaxed = true), setup, mockk(relaxed = true),
|
||||||
|
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
|
||||||
|
val clients = mutableListOf<ConductarrClient>()
|
||||||
|
try {
|
||||||
|
model.load(server, justin)
|
||||||
|
requireNotNull(model.onForeground(server, justin)).join()
|
||||||
|
clients.add(conductarr.identity.value!!.client)
|
||||||
|
val shell = session.state!!
|
||||||
|
shell.key(ShellKey.Ok)
|
||||||
|
for (index in listOf(1, 2, 0)) {
|
||||||
|
val switched = CompletableDeferred<CurrentUser>()
|
||||||
|
every { setup.navigateTo(any()) } answers { switched.complete((firstArg() as SetupDestination.AppContent).current) }
|
||||||
|
shell.focusNav(shell.tabs.size)
|
||||||
|
shell.key(ShellKey.Ok)
|
||||||
|
shell.focusPerson(index)
|
||||||
|
shell.key(ShellKey.Ok)
|
||||||
|
assertEquals(users[index].id.toString(), shell.person.jellyfinUserId)
|
||||||
|
assertEquals("home", shell.route)
|
||||||
|
assertFalse(shell.pickerVisible)
|
||||||
|
assertNull("Old identity must not serve the newly selected person", conductarr.identity.value)
|
||||||
|
val current = withTimeout(5_000) { switched.await() }
|
||||||
|
assertEquals(users[index].id, current.user.id)
|
||||||
|
model.load(current.server, current.user)
|
||||||
|
requireNotNull(model.onForeground(server, current.user)).join()
|
||||||
|
assertEquals(users[index].name!!.lowercase(), conductarr.identity.value!!.operator!!.requester)
|
||||||
|
assertEquals(users[index].name!!.lowercase(), model.signedInPerson)
|
||||||
|
clients.add(conductarr.identity.value!!.client)
|
||||||
|
}
|
||||||
|
conductarr.close()
|
||||||
|
conductarr.close()
|
||||||
|
model.viewModelScope.cancel()
|
||||||
|
releaseCloses.countDown()
|
||||||
|
assertTrue("Every retired client must finish disposal", withContext(Dispatchers.IO) { closed.await(5, TimeUnit.SECONDS) })
|
||||||
|
assertEquals(listOf(false, false, false, false), closeThreads.toList())
|
||||||
|
for (client in clients) {
|
||||||
|
val result = client.read(TvRoutes.operator(), ReadTrigger.FORCE).last()
|
||||||
|
assertEquals("Retired clients cannot start another network read", ClientError.Unreachable, (result as ReadResult.Failure).error)
|
||||||
|
}
|
||||||
|
disposalScope.coroutineContext.job.children.toList().joinAll()
|
||||||
|
assertTrue("A failing socket close must be contained", disposalErrors.isEmpty())
|
||||||
|
} finally {
|
||||||
|
releaseCloses.countDown()
|
||||||
|
model.viewModelScope.cancel()
|
||||||
|
withContext(Dispatchers.IO) { conductarr.close() }
|
||||||
|
disposalScope.coroutineContext.job.children.toList().joinAll()
|
||||||
|
disposalScope.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun rapidPickerChoicesIgnoreLateSignInAndCanReturnToTheOriginalPerson() = runBlocking {
|
||||||
|
FakeConductarr().use { fake ->
|
||||||
|
ConductarrPreference.updateBaseUrl(fake.url)
|
||||||
|
ConductarrSession(MemoryCacheStore()).use { conductarr ->
|
||||||
|
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null)
|
||||||
|
val justin = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = JUSTIN_TOKEN)
|
||||||
|
val users = listOf(justin, justin.copy(id = UUID.randomUUID(), name = "Mindel", accessToken = MINDEL_TOKEN),
|
||||||
|
justin.copy(id = UUID.randomUUID(), name = "Shared", accessToken = SHARED_TOKEN))
|
||||||
|
val repository = mockk<ServerRepository>(relaxed = true)
|
||||||
|
coEvery { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, users)
|
||||||
|
val pending = CompletableDeferred<Unit>()
|
||||||
|
val release = CompletableDeferred<Unit>()
|
||||||
|
coEvery { repository.changeUser(server, any()) } coAnswers {
|
||||||
|
val user = secondArg<JellyfinUser>()
|
||||||
|
if (user.id == users[1].id) {
|
||||||
|
pending.complete(Unit)
|
||||||
|
withContext(NonCancellable) { release.await() }
|
||||||
|
}
|
||||||
|
CurrentUser(server, user)
|
||||||
|
}
|
||||||
|
val switched = CompletableDeferred<CurrentUser>()
|
||||||
|
val setup = mockk<SetupNavigationManager>(relaxed = true)
|
||||||
|
every { setup.navigateTo(any()) } answers { switched.complete((firstArg() as SetupDestination.AppContent).current) }
|
||||||
|
val libraries = mockk<LibraryReadFacade>(relaxed = true)
|
||||||
|
coEvery { libraries.readDestinations(any()) } returns emptyMap()
|
||||||
|
val session = WolfskinSession()
|
||||||
|
val model = WolfskinShellViewModel(context, session, repository, mockk(relaxed = true), setup, mockk(relaxed = true),
|
||||||
|
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
|
||||||
|
try {
|
||||||
|
model.load(server, justin)
|
||||||
|
requireNotNull(model.onForeground(server, justin)).join()
|
||||||
|
val shell = session.state!!
|
||||||
|
shell.key(ShellKey.Ok)
|
||||||
|
for (index in listOf(1, 2, 0)) {
|
||||||
|
shell.focusNav(shell.tabs.size)
|
||||||
|
shell.key(ShellKey.Ok)
|
||||||
|
shell.focusPerson(index)
|
||||||
|
shell.key(ShellKey.Ok)
|
||||||
|
if (index == 1) withTimeout(5_000) { pending.await() }
|
||||||
|
}
|
||||||
|
release.complete(Unit)
|
||||||
|
val current = withTimeout(5_000) { switched.await() }
|
||||||
|
assertEquals(justin.id, current.user.id)
|
||||||
|
verify(exactly = 1) { setup.navigateTo(any()) }
|
||||||
|
model.load(server, current.user)
|
||||||
|
requireNotNull(model.onForeground(server, current.user)).join()
|
||||||
|
assertEquals(justin.id.toString(), shell.person.jellyfinUserId)
|
||||||
|
assertEquals("justin", model.signedInPerson)
|
||||||
|
assertEquals("justin", conductarr.identity.value!!.operator!!.requester)
|
||||||
|
} finally {
|
||||||
|
release.complete(Unit)
|
||||||
|
model.viewModelScope.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun homeAndTitleRetriesReplaceSignedOutClientsWithoutMainThreadDisposal() = runBlocking {
|
||||||
|
val closeThreads = CopyOnWriteArrayList<Boolean>()
|
||||||
|
val closed = CountDownLatch(3)
|
||||||
|
val disposalScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
mockkConstructor(ConductarrClient::class)
|
||||||
|
every { anyConstructed<ConductarrClient>().close() } answers {
|
||||||
|
val onMain = Looper.myLooper() == Looper.getMainLooper()
|
||||||
|
closeThreads.add(onMain)
|
||||||
|
check(!onMain) { "StrictMode: retry socket close on main" }
|
||||||
|
callOriginal()
|
||||||
|
closed.countDown()
|
||||||
|
}
|
||||||
|
FakeConductarr().use { fake ->
|
||||||
|
ConductarrPreference.updateBaseUrl(fake.url)
|
||||||
|
val conductarr = ConductarrSession(MemoryCacheStore(), disposalScope)
|
||||||
|
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null)
|
||||||
|
val user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = JUSTIN_TOKEN)
|
||||||
|
val repository = mockk<ServerRepository>()
|
||||||
|
every { repository.currentUser } returns user
|
||||||
|
every { repository.currentServer } returns server
|
||||||
|
val home = mockk<org.justinvisser.wolfskin.tv.read.HomeReadFacade>()
|
||||||
|
coEvery { home.readWatch(user.id) } returns org.justinvisser.wolfskin.design.HomeLibrary(resume = listOf(
|
||||||
|
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)
|
||||||
|
every { cache.read(any()) } returns null
|
||||||
|
val homeModel = WolfskinHomeViewModel(context, home, cache, mockk(relaxed = true), WolfskinSeeAllLists(), conductarr)
|
||||||
|
val titleModel = WolfskinTitleViewModel(context, mockk(), mockk(), mockk(relaxed = true), conductarr, repository)
|
||||||
|
try {
|
||||||
|
conductarr.select("${server.id}:${user.id}", user.accessToken, fake.url)
|
||||||
|
conductarr.authenticate()
|
||||||
|
fake.status = 401
|
||||||
|
conductarr.authenticate()
|
||||||
|
assertFalse(conductarr.identity.value!!.signedIn)
|
||||||
|
fake.status = 200
|
||||||
|
homeModel.load(server, user, org.justinvisser.wolfskin.design.ShellPerson("justin", "Justin", "J", false, user.id.toString()), retry = true)
|
||||||
|
withTimeout(5_000) { conductarr.identity.first { it?.signedIn == true } }
|
||||||
|
assertEquals("justin", conductarr.identity.value!!.operator!!.requester)
|
||||||
|
assertTrue(homeModel.snapshot.rows.any { row -> row.cards.any { it.id == "justin-film" } })
|
||||||
|
fake.status = 401
|
||||||
|
conductarr.authenticate()
|
||||||
|
assertFalse(conductarr.identity.value!!.signedIn)
|
||||||
|
fake.status = 200
|
||||||
|
titleModel.retrySignIn()
|
||||||
|
assertEquals("justin", conductarr.identity.value!!.operator!!.requester)
|
||||||
|
assertTrue(conductarr.identity.value!!.signedIn)
|
||||||
|
} finally {
|
||||||
|
homeModel.viewModelScope.cancel()
|
||||||
|
titleModel.viewModelScope.cancel()
|
||||||
|
conductarr.close()
|
||||||
|
disposalScope.coroutineContext.job.children.toList().joinAll()
|
||||||
|
disposalScope.cancel()
|
||||||
|
}
|
||||||
|
assertEquals(0L, closed.count)
|
||||||
|
assertEquals(listOf(false, false, false), closeThreads.toList())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
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.playableId(user.id, title) } throws IOException("fixture offline")
|
coEvery { facade.preparePlayback(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,14 +80,16 @@ 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.playableId(user.id, title) } returns id
|
coEvery { facade.preparePlayback(user.id, title) } returns org.jellyfin.sdk.model.api.BaseItemDto(
|
||||||
|
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, resume = true), shell, server, user) }
|
rule.runOnIdle { model.act(HomeAction.Play(title), 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((title.resumeSeconds * 1000).toLong(), destination.positionMs)
|
assertEquals(1_136_274L, 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)
|
||||||
|
|
@ -99,11 +101,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, resume = true), shell, server, otherUser) }
|
rule.runOnIdle { model.act(HomeAction.Play(title), 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.playableId(any(), any()) }
|
coVerify(exactly = 0) { facade.preparePlayback(any(), any()) }
|
||||||
}
|
}
|
||||||
private fun awaitModel(condition: () -> Boolean) {
|
private fun awaitModel(condition: () -> Boolean) {
|
||||||
rule.waitUntil(5000) {
|
rule.waitUntil(5000) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -58,8 +58,9 @@ class ShellStartupTimingTest {
|
||||||
val activity = async(Dispatchers.IO) { conductarr.identity.value!!.provider.requests().last() }
|
val activity = async(Dispatchers.IO) { conductarr.identity.value!!.provider.requests().last() }
|
||||||
withTimeout(5_000) { activityEntered.await() }
|
withTimeout(5_000) { activityEntered.await() }
|
||||||
val savedPeople = CompletableDeferred<Unit>()
|
val savedPeople = CompletableDeferred<Unit>()
|
||||||
|
val savedPeopleStarted = CompletableDeferred<Unit>()
|
||||||
val repository = mockk<ServerRepository>(relaxed = true)
|
val repository = mockk<ServerRepository>(relaxed = true)
|
||||||
coEvery { repository.serverDao.getServer(server.id) } coAnswers { savedPeople.await(); null }
|
coEvery { repository.serverDao.getServer(server.id) } coAnswers { savedPeopleStarted.complete(Unit); savedPeople.await(); null }
|
||||||
val navigation = mockk<NavigationManager>(relaxed = true)
|
val navigation = mockk<NavigationManager>(relaxed = true)
|
||||||
val libraries = mockk<LibraryReadFacade>(relaxed = true)
|
val libraries = mockk<LibraryReadFacade>(relaxed = true)
|
||||||
coEvery { libraries.readDestinations(user.id) } returns emptyMap()
|
coEvery { libraries.readDestinations(user.id) } returns emptyMap()
|
||||||
|
|
@ -68,7 +69,7 @@ class ShellStartupTimingTest {
|
||||||
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
|
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
|
||||||
val load = launch { model.load(server, user) }
|
val load = launch { model.load(server, user) }
|
||||||
try {
|
try {
|
||||||
yield()
|
withTimeout(5_000) { savedPeopleStarted.await() }
|
||||||
val shell = requireNotNull(session.state) { "The current Jellyfin person must paint before saved-login I/O" }
|
val shell = requireNotNull(session.state) { "The current Jellyfin person must paint before saved-login I/O" }
|
||||||
assertEquals(PeopleStatus.Ready, shell.peopleStatus)
|
assertEquals(PeopleStatus.Ready, shell.peopleStatus)
|
||||||
assertEquals(user.id.toString(), shell.person.jellyfinUserId)
|
assertEquals(user.id.toString(), shell.person.jellyfinUserId)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package org.justinvisser.wolfskin.tv
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import android.os.Looper
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.compose.ui.input.key.Key
|
import androidx.compose.ui.input.key.Key
|
||||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
|
@ -35,6 +36,7 @@ import org.justinvisser.wolfskin.design.TitleVersion
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTheme
|
import org.justinvisser.wolfskin.design.WolfskinTheme
|
||||||
import org.justinvisser.wolfskin.tv.read.TitleReadFacade
|
import org.justinvisser.wolfskin.tv.read.TitleReadFacade
|
||||||
import org.robolectric.RobolectricTestRunner
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.Shadows
|
||||||
import org.robolectric.annotation.Config
|
import org.robolectric.annotation.Config
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
|
|
@ -152,7 +154,10 @@ class TitleAsyncFocusTest {
|
||||||
assertEquals("retry", viewModel.title!!.options[viewModel.title!!.column].id)
|
assertEquals("retry", viewModel.title!!.options[viewModel.title!!.column].id)
|
||||||
rule.runOnIdle { viewModel.title!!.toast = null }
|
rule.runOnIdle { viewModel.title!!.toast = null }
|
||||||
press(Key.DirectionCenter)
|
press(Key.DirectionCenter)
|
||||||
rule.waitUntil(timeoutMillis = 5000) { reads.get() == 2 && viewModel.title?.toast != null }
|
rule.waitUntil(timeoutMillis = 5000) {
|
||||||
|
Shadows.shadowOf(Looper.getMainLooper()).idle()
|
||||||
|
reads.get() == 2 && viewModel.title?.toast != null
|
||||||
|
}
|
||||||
rule.waitForIdle()
|
rule.waitForIdle()
|
||||||
assertEquals("Saved title", viewModel.title!!.data.item.name)
|
assertEquals("Saved title", viewModel.title!!.data.item.name)
|
||||||
assertEquals("20:42", shell.navSavedAt)
|
assertEquals("20:42", shell.navSavedAt)
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,9 @@ 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
|
||||||
|
|
@ -62,6 +65,27 @@ 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 }
|
||||||
|
|
@ -312,11 +336,6 @@ 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 ->
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,136 @@
|
||||||
|
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,6 +33,8 @@ 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
|
||||||
|
|
@ -128,7 +130,7 @@ class TitleReadFacadeTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun diskCopyPreservesFileAndNonContiguousStreamIndexesWithoutRatingsBeforeSignIn() = runTest {
|
@Test fun diskCopyPreservesFileAndNonContiguousStreamIndexesWithoutRatingsBeforeSignIn() = runTest {
|
||||||
coEvery { library.getItem(firstId) } returns successResponse(file(firstId))
|
coEvery { library.getItem(firstId) } returns successResponse(file(firstId).copy(userData = playbackUserData(firstId, 200_000_000L)))
|
||||||
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)!!
|
||||||
|
|
@ -139,6 +141,7 @@ 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 {
|
||||||
|
|
@ -218,6 +221,43 @@ 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
|
||||||
|
|
|
||||||
135
app/src/test/java/org/justinvisser/wolfskin/tv/WatchReadTest.kt
Normal file
135
app/src/test/java/org/justinvisser/wolfskin/tv/WatchReadTest.kt
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2568
app/src/test/resources/conductarr/activity-history.json
Normal file
2568
app/src/test/resources/conductarr/activity-history.json
Normal file
File diff suppressed because it is too large
Load diff
8143
app/src/test/resources/conductarr/mindel-feed.json
Normal file
8143
app/src/test/resources/conductarr/mindel-feed.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -9,6 +9,7 @@ import androidx.compose.runtime.setValue
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import com.github.damontecres.wholphin.BuildConfig
|
import com.github.damontecres.wholphin.BuildConfig
|
||||||
|
import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
|
|
@ -16,7 +17,11 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import org.justinvisser.wolfskin.conductarr.CacheEntry
|
||||||
|
import org.justinvisser.wolfskin.conductarr.CacheStore
|
||||||
import org.justinvisser.wolfskin.conductarr.FileCacheStore
|
import org.justinvisser.wolfskin.conductarr.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 {
|
||||||
|
|
@ -50,6 +55,15 @@ object ConductarrPreference {
|
||||||
object ConductarrModule {
|
object ConductarrModule {
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun session(@ApplicationContext context: Context): ConductarrSession =
|
fun session(@ApplicationContext context: Context, @IoCoroutineScope scope: CoroutineScope, dns: PersistentDns): ConductarrSession =
|
||||||
ConductarrSession(FileCacheStore(File(context.noBackupFilesDir, "conductarr")))
|
ConductarrSession(ConductarrDiskCache(context), scope, dns)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Android may create noBackupFilesDir, so resolve it only during an IO cache operation. */
|
||||||
|
private class ConductarrDiskCache(context: Context) : CacheStore {
|
||||||
|
private val store by lazy { FileCacheStore(File(context.noBackupFilesDir, "conductarr")) }
|
||||||
|
override fun read(scope: String, key: String) = store.read(scope, key)
|
||||||
|
override fun write(scope: String, key: String, entry: CacheEntry, capacity: Int?) = store.write(scope, key, entry, capacity)
|
||||||
|
override fun remove(scope: String, key: String) = store.remove(scope, key)
|
||||||
|
override fun clear(scope: String) = store.clear(scope)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,20 +41,62 @@ 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 {
|
||||||
stages.orEmpty().any { it.state == ReadinessStage.State.FAILED } -> ComingInStage.Failed
|
acquisition?.summary == "The title was deleted after it was dispatched." -> ComingInStage.Removed
|
||||||
|
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 -> ComingInStage.Waiting
|
awaitingApproval == true || acquisition?.summary == "Waiting for Seerr's approval to reach dispatch." -> ComingInStage.Waiting
|
||||||
visibility?.state in setOf(ReadinessStage.State.IN_PROGRESS, ReadinessStage.State.PARTIAL) || acquisition?.state == ReadinessStage.State.DONE -> ComingInStage.AlmostReady
|
acquisition?.summary == "Dispatched; waiting for the Arr to import a file." -> ComingInStage.WaitingForFile
|
||||||
acquisition?.state == ReadinessStage.State.IN_PROGRESS -> ComingInStage.Downloading
|
visibility?.state == ReadinessStage.State.IN_PROGRESS || visibility?.state == ReadinessStage.State.PARTIAL || acquisition?.state == ReadinessStage.State.DONE -> ComingInStage.AlmostReady
|
||||||
acquisition?.state == ReadinessStage.State.NONE -> ComingInStage.Searching
|
acquisition?.state == ReadinessStage.State.IN_PROGRESS && acquisition.summary.startsWith("Downloading", ignoreCase = true) -> ComingInStage.Downloading
|
||||||
stages.orEmpty().any { it.state in setOf(ReadinessStage.State.UNKNOWN, ReadinessStage.State.STALE, ReadinessStage.State.CONFLICTING) } -> ComingInStage.Checking
|
acquisition?.state == ReadinessStage.State.NONE && acquisition.summary.startsWith("Searching", ignoreCase = true) -> ComingInStage.Searching
|
||||||
else -> ComingInStage.Waiting
|
acquisition?.state == ReadinessStage.State.IN_PROGRESS || acquisition?.state == ReadinessStage.State.NONE -> 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)
|
requester.name, actor?.requester, actor?.name, stage, summary = detail?.summary,
|
||||||
|
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,10 +57,14 @@ 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 picks = (feed?.movies.orEmpty() + feed?.shows.orEmpty()).map { it.discoverTitle(genres) }
|
val candidates = feed?.movies.orEmpty() + feed?.shows.orEmpty()
|
||||||
val seeds = (feed?.movies.orEmpty() + feed?.shows.orEmpty()).flatMap { candidate ->
|
val picks = candidates.map { it.discoverTitle(genres) }
|
||||||
candidate.contributions.orEmpty().mapNotNull { seed -> seed.title?.let { it to candidate.discoverTitle(genres) } }
|
val seeds = candidates.flatMapIndexed { index, candidate ->
|
||||||
}.groupBy({ it.first }, { it.second }).map { DiscoverSeed(it.key, it.value) }
|
candidate.contributions.orEmpty().filter { !it.title.isNullOrBlank() }.map { it to picks[index] }
|
||||||
|
}.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))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,17 @@ package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
import java.io.Closeable
|
import java.io.Closeable
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import org.justinvisser.wolfskin.conductarr.*
|
import org.justinvisser.wolfskin.conductarr.*
|
||||||
import org.justinvisser.wolfskin.conductarr.model.Operator
|
import org.justinvisser.wolfskin.conductarr.model.Operator
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
/** A selected Jellyfin login owns every provider and all in-memory observations. */
|
/** A selected Jellyfin login owns every provider and all in-memory observations. */
|
||||||
data class ConductarrIdentity(
|
data class ConductarrIdentity(
|
||||||
|
|
@ -21,53 +26,86 @@ data class ConductarrIdentity(
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Tokens are borrowed from upstream, never persisted or exposed as observable state. */
|
/** Tokens are borrowed from upstream, never persisted or exposed as observable state. */
|
||||||
class ConductarrSession(private val cache: CacheStore) : Closeable {
|
class ConductarrSession(
|
||||||
|
private val cache: CacheStore,
|
||||||
|
private val disposalScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
||||||
|
private val dns: PersistentDns? = null,
|
||||||
|
) : Closeable {
|
||||||
private val mutableIdentity = MutableStateFlow<ConductarrIdentity?>(null)
|
private val mutableIdentity = MutableStateFlow<ConductarrIdentity?>(null)
|
||||||
val identity: StateFlow<ConductarrIdentity?> = mutableIdentity.asStateFlow()
|
val identity: StateFlow<ConductarrIdentity?> = mutableIdentity.asStateFlow()
|
||||||
private var selection: ByteArray? = null
|
private var selection: ByteArray? = null
|
||||||
@Volatile private var generation = 0L
|
@Volatile private var generation = 0L
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun select(person: String, token: String?, baseUrl: String, retry: Boolean = false) {
|
fun select(person: String, token: String?, baseUrl: String, retry: Boolean = false) {
|
||||||
val fingerprint = MessageDigest.getInstance("SHA-256")
|
val fingerprint = MessageDigest.getInstance("SHA-256")
|
||||||
.digest("$person\n$baseUrl\n${token.orEmpty()}".toByteArray())
|
.digest("$person\n$baseUrl\n${token.orEmpty()}".toByteArray())
|
||||||
|
val previous: ConductarrClient?
|
||||||
|
val currentGeneration: Long
|
||||||
|
synchronized(this) {
|
||||||
if (!retry && selection?.contentEquals(fingerprint) == true) return
|
if (!retry && selection?.contentEquals(fingerprint) == true) return
|
||||||
generation++
|
generation++
|
||||||
val currentGeneration = generation
|
currentGeneration = generation
|
||||||
mutableIdentity.value?.client?.close()
|
previous = mutableIdentity.value?.client
|
||||||
mutableIdentity.value = null
|
mutableIdentity.value = null
|
||||||
selection = fingerprint
|
selection = fingerprint
|
||||||
if (token.isNullOrBlank()) return
|
}
|
||||||
val client = try {
|
try {
|
||||||
ConductarrClient(baseUrl, token, PersonCacheStore(cache, person), timeouts = ClientTimeouts(3, 5, 8), onEvent = { event ->
|
val client = if (token.isNullOrBlank()) null else try {
|
||||||
if (event == ClientEvent.NEEDS_SIGN_IN && generation == currentGeneration) {
|
ConductarrClient(baseUrl, token, PersonCacheStore(cache, person), timeouts = ClientTimeouts(3, 5, 8), dns = dns, onEvent = { event ->
|
||||||
mutableIdentity.update { it?.copy(operator = null, signedIn = false, error = ClientError.NeedsSignIn) }
|
if (event == ClientEvent.NEEDS_SIGN_IN) {
|
||||||
|
mutableIdentity.update {
|
||||||
|
if (generation == currentGeneration) it?.copy(operator = null, signedIn = false, error = ClientError.NeedsSignIn) else it
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} catch (_: IllegalArgumentException) { return }
|
} catch (_: IllegalArgumentException) { null }
|
||||||
mutableIdentity.value = ConductarrIdentity(person, client, ConductarrProvider(client))
|
synchronized(this) {
|
||||||
|
if (generation == currentGeneration) {
|
||||||
|
mutableIdentity.value = client?.let { ConductarrIdentity(person, it, ConductarrProvider(it)) }
|
||||||
|
} else dispose(client)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
dispose(previous)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Disk identity paints first; a 401 always removes it, even after an earlier saved emission. */
|
/** Disk identity paints first; a 401 always removes it, even after an earlier saved emission. */
|
||||||
suspend fun authenticate() {
|
suspend fun authenticate() {
|
||||||
val selected = mutableIdentity.value ?: return
|
val selected = mutableIdentity.value ?: return
|
||||||
selected.client.read(TvRoutes.operator(), ReadTrigger.LAUNCH).collect { result ->
|
selected.client.read(TvRoutes.operator(), ReadTrigger.LAUNCH).collect { result ->
|
||||||
if (mutableIdentity.value?.client !== selected.client) return@collect
|
|
||||||
when (result) {
|
when (result) {
|
||||||
is ReadResult.Value -> mutableIdentity.update { it?.copy(operator = result.saved.value, signedIn = result.saved.value != null, error = null) }
|
is ReadResult.Value -> mutableIdentity.update {
|
||||||
|
if (it?.client === selected.client) it.copy(operator = result.saved.value, signedIn = result.saved.value != null, error = null) else it
|
||||||
|
}
|
||||||
is ReadResult.Failure -> mutableIdentity.update {
|
is ReadResult.Failure -> mutableIdentity.update {
|
||||||
if (result.error == ClientError.NeedsSignIn) it?.copy(operator = null, signedIn = false, error = result.error)
|
if (it?.client !== selected.client) it
|
||||||
else it?.copy(error = result.error)
|
else if (result.error == ClientError.NeedsSignIn) it.copy(operator = null, signedIn = false, error = result.error)
|
||||||
|
else it.copy(error = result.error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Remove read authority synchronously; socket disposal finishes independently on IO. */
|
||||||
|
@Synchronized
|
||||||
override fun close() {
|
override fun close() {
|
||||||
generation++
|
generation++
|
||||||
mutableIdentity.value?.client?.close()
|
val previous = mutableIdentity.value?.client
|
||||||
mutableIdentity.value = null
|
mutableIdentity.value = null
|
||||||
selection = null
|
selection = null
|
||||||
|
dispose(previous)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dispose(client: ConductarrClient?) {
|
||||||
|
if (client == null) return
|
||||||
|
// The application scope outlives the screen replaced by a person switch.
|
||||||
|
disposalScope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
client.close()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
Timber.w("Conductarr client disposal failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,10 +18,12 @@ 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 kotlinx.coroutines.Dispatchers
|
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||||
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
|
||||||
|
|
@ -50,10 +52,12 @@ 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(Dispatchers.IO) { debugHomeSnapshot(context, person.key) }?.let { snapshot = it; return }
|
withContext(WholphinDispatchers.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) {
|
||||||
conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true)
|
conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
loadLibrary(server, user, person, retry)
|
loadLibrary(server, user, person, retry)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -72,11 +76,12 @@ 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(Dispatchers.IO) { try { cache.read(scope) } catch (_: java.io.IOException) { null } }
|
val saved = withContext(WholphinDispatchers.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() {
|
||||||
|
|
@ -87,8 +92,9 @@ 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),
|
snapshot = HomeSnapshot(buildHomeRows(libraryCopy, catalogueCopy.homeRows(), libraryAvailable = libraryAvailable).map {
|
||||||
status, libraryAvailable, catalogueCopy.savedAt ?: librarySavedAt)
|
if (it.kind == HomeRowKind.Hero) it.copy(watchReady = watchReady) else it
|
||||||
|
}, 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() } }
|
||||||
|
|
@ -105,21 +111,44 @@ 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() {
|
||||||
|
val library = libraryCopy
|
||||||
|
withContext(WholphinDispatchers.IO) {
|
||||||
|
try { cache.write(scope, library, System.currentTimeMillis()) } catch (_: java.io.IOException) { /* Disk failure cannot revoke fresh watch data. */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
coroutineScope {
|
||||||
|
launch {
|
||||||
try {
|
try {
|
||||||
val library = withContext(Dispatchers.IO) { home.read(user.id) }
|
val watch = withContext(WholphinDispatchers.IO) { home.readWatch(user.id) }
|
||||||
val savedAt = System.currentTimeMillis()
|
if (revision != loadRevision) return@launch
|
||||||
libraryCopy = library
|
libraryCopy = libraryCopy.copy(resume = watch.resume, nextUp = watch.nextUp)
|
||||||
|
watchReady = true
|
||||||
|
libraryAvailable = true
|
||||||
publish()
|
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) {
|
} catch (exception: Exception) {
|
||||||
if (exception is CancellationException) throw exception
|
if (exception is CancellationException) throw exception
|
||||||
if (revision != loadRevision) return
|
if (revision != loadRevision) return@launch
|
||||||
libraryAvailable = false
|
libraryAvailable = false
|
||||||
librarySavedAt = saved?.second?.let { SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(it)) }
|
librarySavedAt = saved?.second?.let { SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(it)) }
|
||||||
publish()
|
publish()
|
||||||
if (retry) showToast(RecoveryMessage.StillUnreachable)
|
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?) {
|
||||||
val identity = conductarr.identity.value ?: return
|
val identity = conductarr.identity.value ?: return
|
||||||
|
|
@ -136,6 +165,7 @@ 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)
|
||||||
|
|
@ -144,9 +174,9 @@ class WolfskinHomeViewModel @Inject constructor(
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
val id = withContext(Dispatchers.IO) { home.playableId(user.id, action.title) }
|
val item = withContext(WholphinDispatchers.IO) { home.preparePlayback(user.id, action.title) }
|
||||||
if (id == null) showToast(RecoveryMessage.PlaybackUnavailable)
|
if (item == null) showToast(RecoveryMessage.PlaybackUnavailable)
|
||||||
else navigation.navigateTo(Destination.Playback(id, if (action.resume) (action.title.resumeSeconds * 1000).toLong() else 0L))
|
else if (shell.person.key == personKey && (sourceScope == null || sourceScope == "${server.id}:${user.id}")) navigation.navigateTo(Destination.Playback(item.id, item.playbackPositionMs()))
|
||||||
} catch (exception: Exception) {
|
} catch (exception: Exception) {
|
||||||
if (exception is CancellationException) throw exception
|
if (exception is CancellationException) throw exception
|
||||||
showToast(RecoveryMessage.PlaybackUnavailable)
|
showToast(RecoveryMessage.PlaybackUnavailable)
|
||||||
|
|
@ -202,7 +232,8 @@ 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()
|
||||||
LaunchedEffect(shell.person, server.id, user.id, identity?.client, identity?.signedIn, model.audiences) { model.load(server, user, shell.person) }
|
val watchRefresh = rememberWatchRefresh("${server.id}:${user.id}")
|
||||||
|
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
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
// 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.openCount > 0) refresh()
|
if (state.requestsVisible || state.requests.snapshot.hasUnsettledRequests) refresh()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ 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
|
||||||
|
|
||||||
|
|
@ -79,6 +80,7 @@ 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()
|
||||||
|
|
@ -87,6 +89,16 @@ 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
|
||||||
|
|
@ -132,7 +144,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.takeIf { it.data.status == TitleReadStatus.Ready }?.season, preferences)
|
held = facade.read(heldId, current.requestedSeason, preferences)
|
||||||
current.update(held)
|
current.update(held)
|
||||||
}
|
}
|
||||||
} catch (exception: Exception) {
|
} catch (exception: Exception) {
|
||||||
|
|
@ -210,7 +222,9 @@ class WolfskinTitleViewModel @Inject constructor(
|
||||||
internal suspend fun retrySignIn() {
|
internal suspend fun retrySignIn() {
|
||||||
val user = repository.currentUser ?: return
|
val user = repository.currentUser ?: return
|
||||||
val server = repository.currentServer ?: return
|
val server = repository.currentServer ?: return
|
||||||
|
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)
|
||||||
|
}
|
||||||
conductarr.authenticate()
|
conductarr.authenticate()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -221,13 +235,21 @@ 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 -> navigation.navigateTo(action.item.id.toUuidOrNull()?.let { Destination.MediaItem(it, when (action.item.kind) {
|
is TitleAction.OpenTitle -> {
|
||||||
|
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 -> {
|
||||||
|
|
@ -259,12 +281,15 @@ class WolfskinTitleViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun play(id: UUID?, position: Long) {
|
private fun play(id: UUID?, restart: Boolean) {
|
||||||
|
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")
|
||||||
facade.preparePlayback(id)
|
val item = facade.preparePlayback(id) ?: throw IllegalArgumentException("No playable item")
|
||||||
navigation.navigateTo(Destination.Playback(id, position))
|
if (title !== current || watchScope != playbackScope) return@launch
|
||||||
|
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
|
||||||
|
|
@ -306,7 +331,8 @@ 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()
|
||||||
LaunchedEffect(route, shell.person.key, identity?.client, identity?.signedIn, fixtures) { viewModel.load(route, shell.person, preferences, fixtures, force = true) }
|
val watchRefresh = rememberWatchRefresh(viewModel.watchScope)
|
||||||
|
LaunchedEffect(route, shell.person.key, identity?.client, identity?.signedIn, fixtures, watchRefresh) { viewModel.load(route, shell.person, preferences, fixtures, force = true) }
|
||||||
val title = viewModel.title?.takeIf { it.person.key == shell.person.key }
|
val 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) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
@ -39,6 +39,9 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ensureActive
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
|
|
@ -60,6 +63,7 @@ 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 = {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -85,23 +89,28 @@ class WolfskinShellViewModel @Inject constructor(
|
||||||
var signedInPerson by mutableStateOf("")
|
var signedInPerson by mutableStateOf("")
|
||||||
private set
|
private set
|
||||||
private var identityJob: kotlinx.coroutines.Job? = null
|
private var identityJob: kotlinx.coroutines.Job? = null
|
||||||
private var fixtureMode: Boolean? = null
|
private var personSwitchJob: kotlinx.coroutines.Job? = null
|
||||||
|
private val personSwitchMutex = Mutex()
|
||||||
|
|
||||||
suspend fun load(server: JellyfinServer, user: JellyfinUser) {
|
suspend fun load(server: JellyfinServer, user: JellyfinUser) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
WolfskinScreenPreference.initialize(context)
|
WolfskinScreenPreference.initialize(context)
|
||||||
ConductarrPreference.initialize(context)
|
ConductarrPreference.initialize(context)
|
||||||
|
}
|
||||||
identityJob?.cancel()
|
identityJob?.cancel()
|
||||||
libraries.audiences = null
|
libraries.audiences = null
|
||||||
if (ConductarrPreference.fixtures) conductarr.close()
|
if (ConductarrPreference.fixtures) conductarr.close()
|
||||||
else 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() || fixtureMode != ConductarrPreference.fixtures) {
|
if (session.state == null || session.serverId != server.id.toString() || session.fixtureMode != ConductarrPreference.fixtures) {
|
||||||
session.serverId = server.id.toString()
|
session.serverId = server.id.toString()
|
||||||
fixtureMode = ConductarrPreference.fixtures
|
session.fixtureMode = ConductarrPreference.fixtures
|
||||||
session.state = ShellState(readShellPeople(context, listOf(user)), signedInPerson) { session.onAction(it) }
|
val people = withContext(Dispatchers.IO) { readShellPeople(context, listOf(user)) }
|
||||||
|
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() }
|
||||||
session.state?.updatePeople(readShellPeople(context, saved.ifEmpty { listOf(user) }))
|
val savedPeople = withContext(Dispatchers.IO) { readShellPeople(context, saved.ifEmpty { listOf(user) }) }
|
||||||
|
session.state?.updatePeople(savedPeople)
|
||||||
}
|
}
|
||||||
identityJob = viewModelScope.launch {
|
identityJob = viewModelScope.launch {
|
||||||
if (!ConductarrPreference.fixtures) loadHousehold(user)
|
if (!ConductarrPreference.fixtures) loadHousehold(user)
|
||||||
|
|
@ -149,8 +158,10 @@ class WolfskinShellViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun retryConductarr(server: JellyfinServer, user: JellyfinUser) {
|
suspend fun retryConductarr(server: JellyfinServer, user: JellyfinUser) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl,
|
conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl,
|
||||||
retry = conductarr.identity.value?.signedIn != true)
|
retry = conductarr.identity.value?.signedIn != true)
|
||||||
|
}
|
||||||
loadHousehold(user)
|
loadHousehold(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -191,7 +202,7 @@ class WolfskinShellViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
ShellAction.Pop -> navigation.goBack()
|
ShellAction.Pop -> navigation.goBack()
|
||||||
is ShellAction.HandOff -> navigation.navigateTo(handOffDestination(action.group))
|
is ShellAction.HandOff -> navigation.navigateTo(handOffDestination(action.group))
|
||||||
is ShellAction.Person -> selectPerson(action.person, server, user)
|
is ShellAction.Person -> selectPerson(action.person, server)
|
||||||
ShellAction.RetryPeople -> viewModelScope.launch { retryConductarr(server, user) }
|
ShellAction.RetryPeople -> viewModelScope.launch { retryConductarr(server, user) }
|
||||||
ShellAction.RetryRequests -> viewModelScope.launch {
|
ShellAction.RetryRequests -> viewModelScope.launch {
|
||||||
if (conductarr.identity.value?.signedIn != true) retryConductarr(server, user)
|
if (conductarr.identity.value?.signedIn != true) retryConductarr(server, user)
|
||||||
|
|
@ -201,30 +212,33 @@ class WolfskinShellViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun selectPerson(person: ShellPerson, server: JellyfinServer, user: JellyfinUser) {
|
private fun selectPerson(person: ShellPerson, server: JellyfinServer) {
|
||||||
navigation.replace(Destination.Home())
|
navigation.replace(Destination.Home())
|
||||||
if (person.jellyfinUserId == user.id.toString() || person.key == signedInPerson) return
|
if (person.key == signedInPerson) return
|
||||||
|
identityJob?.cancel()
|
||||||
|
personSwitchJob?.cancel()
|
||||||
|
signedInPerson = ""
|
||||||
conductarr.close()
|
conductarr.close()
|
||||||
libraries.audiences = null
|
libraries.audiences = null
|
||||||
viewModelScope.launch {
|
personSwitchJob = viewModelScope.launch {
|
||||||
|
personSwitchMutex.withLock {
|
||||||
val saved = withContext(Dispatchers.IO) {
|
val saved = withContext(Dispatchers.IO) {
|
||||||
repository.serverDao.getServer(server.id)?.users?.firstOrNull { it.id.toString() == person.jellyfinUserId || it.name.equals(person.name, ignoreCase = true) }
|
repository.serverDao.getServer(server.id)?.users?.firstOrNull { it.id.toString() == person.jellyfinUserId || it.name.equals(person.name, ignoreCase = true) }
|
||||||
}
|
}
|
||||||
if (saved == null || saved.isProtected) {
|
if (saved == null || saved.isProtected) return@launch
|
||||||
signedInPerson = ""
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
val current = repository.changeUser(server, saved)
|
val current = repository.changeUser(server, saved)
|
||||||
|
coroutineContext.ensureActive()
|
||||||
setupNavigation.navigateTo(SetupDestination.AppContent(current))
|
setupNavigation.navigateTo(SetupDestination.AppContent(current))
|
||||||
} catch (exception: Exception) {
|
} catch (exception: Exception) {
|
||||||
if (exception is kotlinx.coroutines.CancellationException) throw exception
|
if (exception is kotlinx.coroutines.CancellationException) throw exception
|
||||||
signedInPerson = ""
|
coroutineContext.ensureActive()
|
||||||
session.state?.apply { peopleStatus = PeopleStatus.Unavailable; pickerVisible = true }
|
session.state?.apply { peopleStatus = PeopleStatus.Unavailable; pickerVisible = true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Settings groups open the fork's own pages; the prototype hand-off panel is never shipped. */
|
/** Settings groups open the fork's own pages; the prototype hand-off panel is never shipped. */
|
||||||
fun handOffDestination(group: String): Destination = when (group) {
|
fun handOffDestination(group: String): Destination = when (group) {
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ fun WolfskinLibraryContent(
|
||||||
context.assets.open("wolfskin-library.json").bufferedReader().use { LibraryFixture(it.readText(), "file:///android_asset/library/") }
|
context.assets.open("wolfskin-library.json").bufferedReader().use { LibraryFixture(it.readText(), "file:///android_asset/library/") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val snapshots = remember(context) { LibrarySnapshotStore(FileCacheStore(File(context.filesDir, "wolfskin-library-cache"))) }
|
val snapshots = remember(context) { lazy { LibrarySnapshotStore(FileCacheStore(File(context.filesDir, "wolfskin-library-cache"))) } }
|
||||||
val grid = remember(shell.person.key, route, view) {
|
val grid = remember(shell.person.key, route, view) {
|
||||||
view.library ?: if (route.startsWith("seeall/")) seeAll.get(route.substringAfter("seeall/").takeIf { it.startsWith("${shell.person.key}/") } ?: "") else LibraryGridState(
|
view.library ?: if (route.startsWith("seeall/")) seeAll.get(route.substringAfter("seeall/").takeIf { it.startsWith("${shell.person.key}/") } ?: "") else LibraryGridState(
|
||||||
title = route.replaceFirstChar { it.uppercase() }, items = emptyList(),
|
title = route.replaceFirstChar { it.uppercase() }, items = emptyList(),
|
||||||
|
|
@ -72,7 +72,8 @@ fun WolfskinLibraryContent(
|
||||||
ShellPlaceholder("See all list unavailable", modifier)
|
ShellPlaceholder("See all list unavailable", modifier)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
LaunchedEffect(grid, grid.refreshRevision, fixture, if (route in LIBRARY_DESTINATIONS) facade.audiences else null) {
|
val watchRefresh = rememberWatchRefresh(cacheScope)
|
||||||
|
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)
|
||||||
|
|
@ -81,7 +82,8 @@ fun WolfskinLibraryContent(
|
||||||
grid.source = withContext(Dispatchers.IO) { fixture!!.library(shell.person.key, route) }
|
grid.source = withContext(Dispatchers.IO) { fixture!!.library(shell.person.key, route) }
|
||||||
grid.status = LibraryStatus.Ready
|
grid.status = LibraryStatus.Ready
|
||||||
} else {
|
} else {
|
||||||
snapshots.read(cacheScope, route, force = grid.refreshRevision > 0) { facade.readLibrary(user.id, route) }.collect { snapshot ->
|
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 ->
|
||||||
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 } }
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ import org.justinvisser.wolfskin.design.HomeTitle
|
||||||
/** Saved Jellyfin projections reuse the app-private atomic cache and are partitioned by server/user. */
|
/** Saved Jellyfin projections reuse the app-private atomic cache and are partitioned by server/user. */
|
||||||
@Singleton
|
@Singleton
|
||||||
class HomeLibraryCache @Inject constructor(@param:ApplicationContext context: Context) {
|
class HomeLibraryCache @Inject constructor(@param:ApplicationContext context: Context) {
|
||||||
private val store = FileCacheStore(File(context.filesDir, "wolfskin-home"))
|
private val store by lazy { FileCacheStore(File(context.filesDir, "wolfskin-home")) }
|
||||||
|
|
||||||
fun read(scope: String): Pair<HomeLibrary, Long>? {
|
fun read(scope: String): Pair<HomeLibrary, Long>? {
|
||||||
val entry = store.read(scope, KEY) ?: return null
|
val entry = store.read(scope, KEY) ?: return null
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,8 @@ 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
|
||||||
suspend fun read(userId: UUID): HomeLibrary {
|
/** Watch rows are publishable without waiting for any library shelf. */
|
||||||
|
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 ->
|
||||||
|
|
@ -35,6 +36,11 @@ 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>()
|
||||||
|
|
@ -46,23 +52,18 @@ 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(resume, next, newest.sortedByDescending { it.addedAt }.take(12), destinations, totals)
|
return HomeLibrary(newest = newest.sortedByDescending { it.addedAt }.take(12), destinations = destinations, destinationTotals = totals)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun playableId(userId: UUID, title: HomeTitle): UUID? {
|
/** Resolve the current checkpoint even when the hero was painted from disk. */
|
||||||
title.id.let { runCatching { UUID.fromString(it) }.getOrNull() }?.let { id ->
|
suspend fun preparePlayback(userId: UUID, title: HomeTitle): BaseItemDto? {
|
||||||
val item = api.userLibraryApi.getItem(userId = userId, itemId = id).content
|
val id = runCatching { UUID.fromString(title.id) }.getOrNull() ?: run {
|
||||||
if (item.type == BaseItemKind.SERIES) {
|
|
||||||
return (api.tvShowsApi.getNextUp(userId = userId, seriesId = id, limit = 1).content.items.firstOrNull()
|
|
||||||
?: api.tvShowsApi.getEpisodes(seriesId = id, userId = userId, limit = 1).content.items.firstOrNull())
|
|
||||||
?.takeIf { it.type == BaseItemKind.EPISODE }?.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)
|
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 {
|
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))
|
it.name == title.title && (!title.isEpisode || (it.seriesName == title.seriesTitle && it.parentIndexNumber == title.season && it.indexNumber == title.episode))
|
||||||
}?.id
|
}?.id
|
||||||
|
} ?: return null
|
||||||
|
return readPlayback(api, userId, 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.SORT_NAME, ItemFields.DATE_CREATED),
|
fields = listOf(ItemFields.PROVIDER_IDS, 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, load: () -> Flow<List<LibraryTitle>>): Flow<LibrarySnapshot> = flow {
|
fun read(scope: String, destination: String, force: Boolean = false, invalidatedAtMillis: Long = 0L, 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 && now() - saved.validatedAtMillis < lifetime) return@flow
|
if (!force && saved != null && titles != null && saved.validatedAtMillis > invalidatedAtMillis && now() - saved.validatedAtMillis < lifetime) return@flow
|
||||||
var current: List<LibraryTitle> = emptyList()
|
var current: List<LibraryTitle> = emptyList()
|
||||||
try {
|
try {
|
||||||
load().collect { chunk ->
|
load().collect { chunk ->
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
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,6 +35,7 @@ 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
|
||||||
|
|
@ -52,7 +53,8 @@ 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 fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.MEDIA_SOURCES, ItemFields.MEDIA_STREAMS, ItemFields.OVERVIEW, ItemFields.PEOPLE, ItemFields.PARENT_ID)
|
private val episodeFields = listOf(ItemFields.PROVIDER_IDS, ItemFields.OVERVIEW)
|
||||||
|
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? {
|
||||||
|
|
@ -76,7 +78,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() })
|
.copy(savedAt = savedTime(file), trailerUrl = saved.optString("trailer").takeIf { it.isNotEmpty() }, watchReady = false)
|
||||||
} 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 }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -87,12 +89,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, fields = fields, enableResumable = true)).content.items.firstOrNull()?.let { api.userLibraryApi.getItem(it.id).content }
|
api.tvShowsApi.getNextUp(GetNextUpRequest(seriesId = series.id, userId = repository.currentUser?.id, limit = 1, enableResumable = true)).content.items.firstOrNull()?.let { readItem(it.id) }
|
||||||
}
|
}
|
||||||
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 = fields)).content.items.orEmpty() }.orEmpty()
|
val episodes = selected?.let { api.tvShowsApi.getEpisodes(GetEpisodesRequest(seriesId = series.id, seasonId = it.id, fields = episodeFields)).content.items.orEmpty() }.orEmpty()
|
||||||
val target = next ?: episodes.firstOrNull { it.type == BaseItemKind.EPISODE }
|
val target = next ?: episodes.firstOrNull { it.type == BaseItemKind.EPISODE }?.let { readItem(it.id) }
|
||||||
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)
|
||||||
|
|
@ -121,8 +123,9 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A failed reachability read leaves navigation on the title instead of opening a dead player. */
|
/** Series actions re-resolve NextUp; explicit episodes and Restart retain their exact identity. */
|
||||||
suspend fun preparePlayback(id: UUID) { api.userLibraryApi.getItem(id) }
|
suspend fun preparePlayback(id: UUID): BaseItemDto? =
|
||||||
|
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) {
|
||||||
|
|
@ -163,7 +166,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 = fields, startIndex = start, limit = 200,
|
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), fields = creditFields, 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")
|
||||||
|
|
@ -189,27 +192,44 @@ 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) })
|
||||||
}
|
}
|
||||||
return TitlePageData(mapItem(item).copy(seasons = seasonNumbers.size), target?.takeIf { it.type == BaseItemKind.EPISODE || (it.userData?.playbackPositionTicks ?: 0) > 0 }?.let { mapItem(it) },
|
val pageItem = mapItem(item).copy(seasons = seasonNumbers.size)
|
||||||
rows.first { it.id.toString() == watchId }.let { mapItem(it) }, versions, chosen?.source?.id, chosen?.audioStream?.stream?.index,
|
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, 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) },
|
episodes = rows.filter { it.type == BaseItemKind.EPISODE && it.parentIndexNumber == selectedSeason }.map { mapItem(it, pageItem.backdrop) },
|
||||||
connection = TitleConnection.SignedOut, seasonNumbers = seasonNumbers, selectedSeason = selectedSeason)
|
connection = TitleConnection.SignedOut, seasonNumbers = seasonNumbers, selectedSeason = selectedSeason)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun mapItem(item: BaseItemDto): TitleItem = TitleItem(
|
private fun mapItem(item: BaseItemDto, seriesBackdrop: String? = null): 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 = item.backdropImageTags?.firstOrNull()?.let { images.getItemImageUrl(item.id, ImageType.BACKDROP, maxWidth = 1280, maxHeight = 720, 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) },
|
||||||
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,6 +67,7 @@ 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
|
||||||
|
|
@ -74,6 +75,7 @@ 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)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
// 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,6 +2,10 @@
|
||||||
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
|
||||||
|
|
@ -14,13 +18,24 @@ fun main() = runBlocking {
|
||||||
try {
|
try {
|
||||||
MockWebServer().use { server ->
|
MockWebServer().use { server ->
|
||||||
server.start()
|
server.start()
|
||||||
val baseUrl = server.url("/").toString()
|
val baseUrl = server.url("/").newBuilder().host("localhost").build().toString()
|
||||||
|
var resolutions = 0
|
||||||
|
val dead = InetAddress.getByName("127.0.0.2")
|
||||||
|
val live = InetAddress.getByName("127.0.0.1")
|
||||||
|
val dns = PersistentDns({ File(directory, "dns.json") }, Dns {
|
||||||
|
resolutions++
|
||||||
|
if (resolutions == 1) listOf(dead) else listOf(live)
|
||||||
|
}, Executor { it.run() })
|
||||||
|
check(dns.lookup("localhost") == listOf(dead))
|
||||||
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)).use { client ->
|
ConductarrClient(baseUrl, TEST_TOKEN, FileCacheStore(directory), dns = dns).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")
|
||||||
}
|
}
|
||||||
ConductarrClient(baseUrl, TEST_TOKEN, FileCacheStore(directory)).use { client ->
|
val reopened = PersistentDns({ File(directory, "dns.json") }, Dns { listOf(live) }, Executor { it.run() })
|
||||||
|
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())
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,160 @@
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
61
data/tv-spec/spec.md
Normal file
61
data/tv-spec/spec.md
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
# Wolfskin TV specification — revision 5
|
||||||
|
|
||||||
|
2026-11-06. This feedback amendment applies to the approved revision-4 specification and frozen frames at `data/tv-v2/focus-2026-11-06/` (prototype SHA-256 `1bb7f4097a5303a58e45ec5e61cf867fa6cfdcbfe12fc7a196f00acacae5e48d`). Unchanged geometry, navigation, typography, colours and motion remain governed by those frames and the [written specification](https://pages.justinvisser.org/wolfskin/tv-spec). The amendment's prototype delta is `data/tv-v2/feedback-2026-11-06/`.
|
||||||
|
|
||||||
|
## Who’s watching
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## 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`.
|
||||||
|
|
||||||
|
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.
|
||||||
50
data/tv-v2/feedback-2026-11-06/delta.json
Normal file
50
data/tv-v2/feedback-2026-11-06/delta.json
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
36
data/tv-v2/feedback-2026-11-06/index.html
Normal file
36
data/tv-v2/feedback-2026-11-06/index.html
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
<!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>
|
||||||
351
docker/README.md
351
docker/README.md
|
|
@ -1,8 +1,8 @@
|
||||||
# Wolfskin TV container builds
|
# Wolfskin TV container builds
|
||||||
|
|
||||||
Run from a full Git checkout with the upstream `v*` and `p*` tags available. The
|
Run from a full Git checkout with the upstream `v*` and `p*` tags available. The
|
||||||
host needs Docker, GNU Make, Bash, Git, and `flock` (util-linux), not Java or the
|
host needs Docker, GNU Make, Bash, Git, `realpath` (GNU coreutils), and `flock`
|
||||||
Android SDK.
|
(util-linux), not Java or the Android SDK.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git fetch origin --tags
|
git fetch origin --tags
|
||||||
|
|
@ -27,6 +27,143 @@ Upstream enables JVM unit tests only for debug variants; there is no
|
||||||
`testDefaultReleaseUnitTest` task. `make test` uses the closest default-flavor
|
`testDefaultReleaseUnitTest` task. `make test` uses the closest default-flavor
|
||||||
task without changing upstream's test configuration.
|
task without changing upstream's test configuration.
|
||||||
|
|
||||||
|
### Full-suite test diagnostics
|
||||||
|
|
||||||
|
The Gradle daemon heap (`org.gradle.jvmargs` in `gradle.properties`) is separate
|
||||||
|
from the forked test-worker heap. The default test worker uses 512 MiB; changing
|
||||||
|
the daemon's `-Xmx` does not increase it. A larger worker heap did not resolve
|
||||||
|
the person-picker fixture's missing Compose snapshot notifications.
|
||||||
|
|
||||||
|
Gradle 9.8.2 runs previously failed test classes first on the next invocation.
|
||||||
|
An immediate green `make test` rerun can therefore hide an order-sensitive
|
||||||
|
failure rather than prove a fix. For comparisons, clear only generated binary
|
||||||
|
test-result history (`build/test-results/<task>/binary` in each module) before
|
||||||
|
each run and record actual class-start order; XML file listing order is not
|
||||||
|
execution order. Keep worker heap and parallelism unchanged between samples.
|
||||||
|
`PersonPickerSessionTest` must also pass late in the full app JVM, not just alone
|
||||||
|
or promoted to the front by previous failures.
|
||||||
|
|
||||||
|
## Saved-person launch and DNS
|
||||||
|
|
||||||
|
Launch restores an unprotected saved Jellyfin login from local storage before
|
||||||
|
network validation. The existing Wolfskin picker paints the saved names and
|
||||||
|
identity-derived colors, then refreshes the household in the background. PIN and
|
||||||
|
require-login profiles still go through the existing protected sign-in flow.
|
||||||
|
`Users/Me` and `System/Info/Public` now start concurrently; a late launch response
|
||||||
|
cannot replace a different selected person. Name revalidation updates only the
|
||||||
|
name column and reloads the saved user, preserving newer settings and PINs.
|
||||||
|
|
||||||
|
Jellyfin, Conductarr, artwork, playback and crash-report clients share one
|
||||||
|
`PersistentDns` instance through the application graph. It keeps system-ordered
|
||||||
|
IPv6/IPv4 answers in memory and `noBackupFilesDir/wolfskin-dns.json` (at most 32 hosts).
|
||||||
|
Saved answers return immediately for up to 24 hours and refresh off main after
|
||||||
|
60 seconds; failed background lookups back off for 2 seconds without extending
|
||||||
|
the last successful timestamp. Unexpected resolver exceptions are contained at
|
||||||
|
the background refresh and warm-up boundaries. Negative answers are not persisted or cached.
|
||||||
|
Missing, oversized or corrupt hint files fall back to system resolution.
|
||||||
|
Application creation warms the saved Jellyfin host and configured Conductarr
|
||||||
|
host on parallel IO jobs; it does not change Android Private DNS.
|
||||||
|
|
||||||
|
A connection-establishment failure before any HTTP request headers were sent
|
||||||
|
gets one fresh system lookup and one retry. Responses, read failures and
|
||||||
|
uncertain mutation outcomes are not replayed. Conductarr retains its existing
|
||||||
|
no-automatic-replay policy after a request starts. A DNS refresh changes the next
|
||||||
|
new connection; already-working pooled connections are not forcibly closed.
|
||||||
|
OkHttp's derived WebSocket clients disable event listeners; those calls bypass
|
||||||
|
the HTTP retry bookkeeping while retaining the shared DNS resolver.
|
||||||
|
|
||||||
|
Device acceptance (not established by JVM tests): with saved people present,
|
||||||
|
cold-launch six times more than 60 seconds apart and require the picker within
|
||||||
|
0.5 seconds on every launch. Press Left/Right then one OK; the selected Home must
|
||||||
|
open without a second picker. Open the avatar picker, change person with one OK,
|
||||||
|
and repeat after an offline launch to confirm saved people still paint.
|
||||||
|
|
||||||
|
Compare timestamps of the launch pair of SDK log lines
|
||||||
|
`GET https://jelly.justinvisser.org/Users/Me` and
|
||||||
|
`GET https://jelly.justinvisser.org/System/Info/Public` (ignore later foreground
|
||||||
|
`Users/Me` refreshes). The spike's two slow launch gaps were 1.508 and 1.509
|
||||||
|
seconds. The requests now overlap, so either may log first; the picker must not
|
||||||
|
wait for either response. The minified SDK log tag is not stable across builds.
|
||||||
|
|
||||||
|
## Optimized side-by-side comparison
|
||||||
|
|
||||||
|
The `optimized` build type inherits `release`: R8 optimization/minification,
|
||||||
|
resource shrinking, non-debuggable code, dependency consumer keep rules and
|
||||||
|
dependency baseline profiles. It changes only signing (the Android debug key),
|
||||||
|
package (`org.justinvisser.wolfskin.tv.opt`) and launcher label (`Wolfskin (Opt)`).
|
||||||
|
The launcher icon/banner and release-compatible version name are unchanged:
|
||||||
|
the shared upgrade/updater parser rejects extra suffixes such as `-opt`.
|
||||||
|
`make apk` remains the clean, release-key-signed production build. Debug and
|
||||||
|
optimized containers share the persistent debug key described below; release
|
||||||
|
signing is unchanged.
|
||||||
|
|
||||||
|
Build all four default-flavor outputs in one invocation through the container
|
||||||
|
wrapper, without a release keystore. On a host whose Gradle cache already holds
|
||||||
|
the pinned native extensions and their metadata:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bash docker/gradle.sh optimized --offline \
|
||||||
|
-PWholphinExtensionsUsername=JustinZeus \
|
||||||
|
-PWholphinExtensionsPassword=unused \
|
||||||
|
:app:assembleDefaultOptimized
|
||||||
|
```
|
||||||
|
|
||||||
|
The unused password is not a credential: offline resolution never contacts
|
||||||
|
GitHub Packages. A cold cache instead needs a read-packages credential supplied
|
||||||
|
through the existing `ORG_GRADLE_PROJECT_WholphinExtensionsUsername` and
|
||||||
|
`ORG_GRADLE_PROJECT_WholphinExtensionsPassword` hooks inside the build container,
|
||||||
|
or securely seeded cached artifacts. No release signing inputs are needed.
|
||||||
|
Without native extension configuration, the existing dependency selection uses
|
||||||
|
the MPV stub and omits ffmpeg/AV1, just like `make apk-debug`; that is not a
|
||||||
|
playback-complete comparison. Inspect native libraries before installing.
|
||||||
|
|
||||||
|
Outputs are in `app/build/outputs/apk/default/optimized/`: universal, arm64-v8a,
|
||||||
|
armeabi-v7a and x86_64. Keep
|
||||||
|
`app/build/outputs/mapping/defaultOptimized/mapping.txt` with those APKs to
|
||||||
|
deobfuscate crashes. This variant shares release library variants and the release
|
||||||
|
`debugHomeSnapshot` implementation, not `src/debug` or its preview JSON/artwork.
|
||||||
|
`BuildConfig.DEBUG` remains false: no preview-fixture setting, debug StrictMode
|
||||||
|
or Compose diagnostic stack traces.
|
||||||
|
|
||||||
|
This is a fresh app sandbox: sign in with Jellyfin Quick Connect or the normal
|
||||||
|
server/user flow again; it does not inherit the debug/release app's logins or
|
||||||
|
preferences. **Wolfskin screens** defaults on, and the same Advanced switch can
|
||||||
|
restore upstream screens. The same Conductarr door
|
||||||
|
(`https://wolfskin-tv.justinvisser.org`) and URL override apply. The selected
|
||||||
|
Jellyfin user's token is borrowed for Conductarr; no app-specific TV credential
|
||||||
|
or embedded secret is added. All other settings retain release behavior,
|
||||||
|
including update checks; a production release APK is not an update for this
|
||||||
|
separate package/debug signer.
|
||||||
|
|
||||||
|
`androidx.profileinstaller` and library-provided profiles already exist. There
|
||||||
|
is no recorded app baseline-profile source or Macrobenchmark module.
|
||||||
|
`src/optimized/baselineProfiles/baseline-prof.txt` adds a hand-maintained seed: application and
|
||||||
|
activity classes plus their `onCreate` methods, `WolfskinAppKt`,
|
||||||
|
`WolfskinScreenPreference` and `ConductarrPreference`. AGP merges it with library
|
||||||
|
profiles, rewrites it through R8 and packages `assets/dexopt/baseline.prof` and
|
||||||
|
`baseline.profm`. It is not a measured navigation profile or evidence of a speedup.
|
||||||
|
To replace the seed, add an AndroidX Baseline Profile producer/Macrobenchmark
|
||||||
|
module, record cold launch, Quick Connect/sign-in, person selection, Home,
|
||||||
|
Libraries, Discover, title pages and playback on a suitable device, then rebuild
|
||||||
|
and compare startup/frame timings. Device-driven recording and runtime checks
|
||||||
|
remain separate from an APK build.
|
||||||
|
The optimized source set explicitly registers that profile directory; a bare
|
||||||
|
`src/optimized/baseline-prof.txt` is not consumed by this AGP configuration.
|
||||||
|
|
||||||
|
Static verification: use `aapt2 dump badging` for package/label/SDKs and absence
|
||||||
|
of `application-debuggable`; `apksigner verify --print-certs` for the debug
|
||||||
|
signer; `unzip -l` for both dexopt assets, native ABI contents and absence of
|
||||||
|
`assets/wolfskin-*`/`assets/library/` fixtures. Review R8 warnings and effective
|
||||||
|
consumer rules rather than adding blanket keeps. JVM tests do not prove
|
||||||
|
minified Hilt/Room/serialization/JNI runtime safety; exercise sign-in, real
|
||||||
|
Conductarr reads, artwork, database reopen and each player on the comparison box.
|
||||||
|
`help --task :app:testDefaultOptimizedUnitTest` confirms that this non-debug
|
||||||
|
variant has no JVM unit-test task, just like release. The existing debug JVM
|
||||||
|
`VersionCompareTests` receives the actual optimized version name from the variant
|
||||||
|
API and checks it with the same `Version.fromString` used by upgrade/update handling.
|
||||||
|
Build logic is otherwise checked through actual APK assembly and static inspection.
|
||||||
|
|
||||||
|
|
||||||
## Upstream v1.1.0 integration
|
## Upstream v1.1.0 integration
|
||||||
|
|
||||||
The release merge brings Kotlin `2.4.21`, Gradle `9.8.2`, AGP `9.5.0`, and
|
The release merge brings Kotlin `2.4.21`, Gradle `9.8.2`, AGP `9.5.0`, and
|
||||||
|
|
@ -90,7 +227,7 @@ The stroke is inset within the fixed 20 dp / 40 px height; horizontal border-box
|
||||||
padding includes it. The 6 px dash / 4 px gap follows the browser's 3:2 stroke-width
|
padding includes it. The 6 px dash / 4 px gap follows the browser's 3:2 stroke-width
|
||||||
ratios. Resting borders are white at 22% opacity; focused borders use ink at 40%.
|
ratios. Resting borders are white at 22% opacity; focused borders use ink at 40%.
|
||||||
|
|
||||||
The small gear under the final person opens Settings with Requests focused.
|
The small gear centred beneath the complete people row opens Settings with Requests focused.
|
||||||
Settings groups hand off to existing upstream pages; Requests opens the household
|
Settings groups hand off to existing upstream pages; Requests opens the household
|
||||||
Coming-in sheet described below. **Advanced settings → Wolfskin screens** is the persistent
|
Coming-in sheet described below. **Advanced settings → Wolfskin screens** is the persistent
|
||||||
rollout switch: turning it off renders upstream Home and library destinations with
|
rollout switch: turning it off renders upstream Home and library destinations with
|
||||||
|
|
@ -104,12 +241,44 @@ household labels, requester capabilities and library audiences after sign-in.
|
||||||
The locked shell projection in `app/src/debug/assets/wolfskin-shell.json` is used
|
The locked shell projection in `app/src/debug/assets/wolfskin-shell.json` is used
|
||||||
only when the Advanced debug fixture switch is enabled; release excludes it.
|
only when the Advanced debug fixture switch is enabled; release excludes it.
|
||||||
|
|
||||||
|
Person selection immediately clears the old Conductarr identity and Jellyfin
|
||||||
|
read gate before saved-login I/O. Detached clients are disposed exactly once on
|
||||||
|
the application's IO scope, after the replacement (or signed-out) identity is
|
||||||
|
published; a slow or failing socket close cannot block the picker or abort a
|
||||||
|
switch. Cleanup outlives the replaced screen. Retry and session replacement use
|
||||||
|
the same disposal owner. Superseded sign-ins are cancelled and serialized so a
|
||||||
|
late reply cannot navigate back to an earlier person.
|
||||||
|
|
||||||
|
The fixture-mode marker belongs to the singleton `WolfskinSession`, alongside its
|
||||||
|
server and shell state, not to the user-scoped ViewModel. Switching people replaces
|
||||||
|
the upstream app entry and ViewModel but retains the same-server, same-mode shell:
|
||||||
|
the picker stays closed on Home after the first OK. A cold start still opens it,
|
||||||
|
and the nav avatar explicitly reopens it. `PersonPickerSessionTest` drives the real
|
||||||
|
shell and user-entry replacement for launch/avatar Left and Right, the initial
|
||||||
|
person, gear and back, and fast Left-Left-OK; each selection emits one person action.
|
||||||
|
|
||||||
|
Preference and people-fixture reads, Conductarr client construction (including
|
||||||
|
Home/title retries), and private-cache directory initialization run on IO.
|
||||||
|
`PersonSwitchThreadingTest` replays the real picker across Justin → Mindel →
|
||||||
|
Shared → Justin while all closes are held pending, verifies retired clients
|
||||||
|
cannot start reads, contains a failing close, and checks a burst of choices
|
||||||
|
against a late sign-in reply. The thread-recording close fake rejects the main
|
||||||
|
Looper; actual TLS/StrictMode verification remains a real-device check.
|
||||||
|
|
||||||
Discover adds the picks hero, type chips, catalogue shelves, service pages, continuous
|
Discover adds the picks hero, type chips, catalogue shelves, service pages, continuous
|
||||||
See all grids, Hidden picks, request chooser and receipts. Its `discover-title/...`
|
See all grids, Hidden picks, request chooser and receipts. Its `discover-title/...`
|
||||||
destination is an explicit title-family integration stub exposing only Discover's
|
destination is an explicit title-family integration stub exposing only Discover's
|
||||||
request and pick actions. Unheld titles have no trailer control until the pinned
|
request and pick actions. Unheld titles have no trailer control until the pinned
|
||||||
Conductarr title contract supplies a trailer.
|
Conductarr title contract supplies a trailer.
|
||||||
|
|
||||||
|
Named “Because you watched” shelves require watched contribution evidence.
|
||||||
|
Seeds are grouped by media type and TMDB identity, never by caption; repeated
|
||||||
|
contributions retain both watched/added flags. The current feed has no per-seed
|
||||||
|
watch timestamp, so eligible shelves keep wire order. Added-only contributions
|
||||||
|
remain available in the general For-you collections, but make no named shelf.
|
||||||
|
Titleless contributions do not make a shelf. Recommendations remain acquisition
|
||||||
|
suggestions, not a filter to the viewer's current library.
|
||||||
|
|
||||||
The debug-only fixture and local art live in
|
The debug-only fixture and local art live in
|
||||||
`app/src/debug/assets/wolfskin-discover/`. The Advanced preview switch selects a
|
`app/src/debug/assets/wolfskin-discover/`. The Advanced preview switch selects a
|
||||||
network-free fixture host; real providers are the default in debug and release.
|
network-free fixture host; real providers are the default in debug and release.
|
||||||
|
|
@ -204,16 +373,29 @@ an untouched fresh entry focuses hero button zero; intervening nav input prevent
|
||||||
that automatic transfer. Home, Libraries and Discover use the same `ShellContentFocus`
|
that automatic transfer. Home, Libraries and Discover use the same `ShellContentFocus`
|
||||||
release/repeat guard and Back ladder.
|
release/repeat guard and Back ladder.
|
||||||
|
|
||||||
`HomeReadFacade` is the only Home code importing Jellyfin API clients. Release
|
`HomeReadFacade` confines Home SDK reads to the selected user. Resume and Next up
|
||||||
variants read the selected user's Resume, Next up, newest titles and destination
|
publish independently of slow or failed library shelves; saved metadata still
|
||||||
shelves, with app-private last-known-good projections through the shared atomic
|
paints immediately, but a saved hero offers Details until watch data revalidates.
|
||||||
disk cache. A missing saved library becomes a recovery panel; a saved library
|
Home and title Play actions resolve the current Jellyfin item and its current
|
||||||
stays visible when Jellyfin is unreachable. Playback resolves an actual film or
|
checkpoint at the click, preserving millisecond positions. Series actions use
|
||||||
episode and checks Jellyfin before opening upstream's player. No season,
|
live NextUp with resumable episodes enabled; an explicitly opened episode stays
|
||||||
recommendation stub or different person's session is promoted to playback.
|
that episode, and Restart deliberately starts that displayed episode at zero.
|
||||||
|
Played flags do not discard a positive checkpoint for an intentional rewatch.
|
||||||
|
No season, recommendation stub or different person's session becomes playable.
|
||||||
An unresolved person switch guards Home and both owned/upstream library content
|
An unresolved person switch guards Home and both owned/upstream library content
|
||||||
before mounting any source read with the prior person's Jellyfin session.
|
before mounting any source read with the prior person's Jellyfin session.
|
||||||
|
|
||||||
|
Home and title watch reads refresh on entry and foreground. A successful
|
||||||
|
PlaybackStopped acknowledgement publishes a revision for the captured server/user,
|
||||||
|
so a return read that beat the stop write is refreshed again after that write.
|
||||||
|
The acknowledgement also removes that user's persisted library grid snapshots
|
||||||
|
instead of waiting for their ten-minute TTL; an in-memory revision bypasses any
|
||||||
|
older snapshot that raced invalidation. Stop-report failures remain logged and
|
||||||
|
do not publish a successful acknowledgement. External players refresh after a
|
||||||
|
supported stop result; an external app that returns no position cannot supply a
|
||||||
|
missing checkpoint. Another client's changes after the final read are outside
|
||||||
|
that click's snapshot.
|
||||||
|
|
||||||
Debug variants additionally package the locked fixture and local artwork under
|
Debug variants additionally package the locked fixture and local artwork under
|
||||||
`wolfskin-design/src/debug/assets/wolfskin-home/`; release variants exclude them.
|
`wolfskin-design/src/debug/assets/wolfskin-home/`; release variants exclude them.
|
||||||
The explicit fixture provider supplies approved household data and Home states
|
The explicit fixture provider supplies approved household data and Home states
|
||||||
|
|
@ -248,14 +430,24 @@ are recorded differences, not a claim of browser pixel equality.
|
||||||
|
|
||||||
## Requests under Settings
|
## Requests under Settings
|
||||||
|
|
||||||
The Requests row and Coming-in sheet share one immutable household snapshot.
|
The Requests row and Coming-in sheet share one immutable household history snapshot.
|
||||||
Ready requests do not count as open. Rows sort Failed, Downloading/Almost ready,
|
Settings counts returned requests, including Ready, rather than calling every
|
||||||
Checking/Searching, Waiting, Ready. The bounded window shows five rows, or four
|
non-Ready row “open”. An unavailable empty snapshot has no count. Deleted rows
|
||||||
with readiness expanded. Right expands Request/Download/Library/Playback; Left
|
read Removed; absent holdings read Not in any library; unreleased titles read
|
||||||
collapses; Up/Down collapse while moving. Opening a title preserves the request
|
Not out yet; holdings outside the recipient's libraries read In another library.
|
||||||
for Back, and closing the sheet restores its opener. Selecting a root or changing
|
Waiting for an import is not evidence of an active download. Downloading and
|
||||||
person clears the title-return link. Shared's authenticated actor always reads
|
Searching require explicit acquisition signals. Visibility-proof faults read
|
||||||
“by Shared”, never “by you”.
|
Check needed; playback faults read File health needs attention, even when a
|
||||||
|
library holding is present. Right expands the actual stage evidence and the
|
||||||
|
relevant summary, including episode/source names when supplied by that summary.
|
||||||
|
The activity feed currently supplies only a title-level summary for Avatar.
|
||||||
|
|
||||||
|
Attention-needed rows sort first, then active acquisition/checks, waiting and
|
||||||
|
settled history. The bounded window shows five rows, or four with readiness
|
||||||
|
expanded. Left collapses; Up/Down collapse while moving. Opening a title preserves
|
||||||
|
the request for Back, and closing the sheet restores its opener. Selecting a root
|
||||||
|
or changing person clears the title-return link. Shared's authenticated actor
|
||||||
|
always reads “by Shared”, never “by you”; absent actors have no “by” clause.
|
||||||
|
|
||||||
The debug fixture switch loads `app/src/debug/assets/wolfskin-requests.json` and
|
The debug fixture switch loads `app/src/debug/assets/wolfskin-requests.json` and
|
||||||
its eight local posters. Shared and focused-rating records also feed the native
|
its eight local posters. Shared and focused-rating records also feed the native
|
||||||
|
|
@ -307,6 +499,15 @@ type and TMDB identity. Back restores the exact Coming-in row and expansion;
|
||||||
the temporary `request-title/...` placeholder is removed. TV sign-in, release
|
the temporary `request-title/...` placeholder is removed. TV sign-in, release
|
||||||
and device deployment remain separate.
|
and device deployment remain separate.
|
||||||
|
|
||||||
|
`ActivityTruthProjectionTest` feeds recorded activity JSON through the TV adapter
|
||||||
|
for Steins;Gate, Yu Yu Hakusho, Love is Blind, Spider-Man: Homecoming, deleted
|
||||||
|
One Hour Photo, unreleased The Body, Avatar health and Partition waiting.
|
||||||
|
`ConductarrTruthCaptureTest` renders those provider snapshots and Mindel's recorded
|
||||||
|
recommendation feed on the native Compose surfaces at 1920×1080, writing
|
||||||
|
`build/wolfskin-captures/truth/`. Artwork is deliberately omitted to keep these
|
||||||
|
captures offline; missing-art rendering, tokens, focus, sheet geometry and shelf
|
||||||
|
spacing are the shipped components.
|
||||||
|
|
||||||
## Libraries and See all
|
## Libraries and See all
|
||||||
|
|
||||||
Movies, Shows and Anime use the shell's retained view and a continuous six-column
|
Movies, Shows and Anime use the shell's retained view and a continuous six-column
|
||||||
|
|
@ -360,7 +561,7 @@ Discover and season-chip rows retain their 104 px height.
|
||||||
Cards and cast circles draw a single 4 px accent stroke centred on the art edge,
|
Cards and cast circles draw a single 4 px accent stroke centred on the art edge,
|
||||||
above the content, with no background gap. Card lift parameters are unchanged;
|
above the content, with no background gap. Card lift parameters are unchanged;
|
||||||
cast portraits and episode tiles now receive that lift too. The stroke and art
|
cast portraits and episode tiles now receive that lift too. The stroke and art
|
||||||
scale together at 1.08; buttons, avatars, chips and picker focus are unchanged.
|
scale together at 1.08.
|
||||||
Title shelves reserve vertical drawing room for the scaled ring.
|
Title shelves reserve vertical drawing room for the scaled ring.
|
||||||
Before scaling the stroke, ring-to-neighbour clearances round to 28/22/16 px
|
Before scaling the stroke, ring-to-neighbour clearances round to 28/22/16 px
|
||||||
for poster/tile/wide cards; including stroke scale they are 27.6/21.8/16.1 px.
|
for poster/tile/wide cards; including stroke scale they are 27.6/21.8/16.1 px.
|
||||||
|
|
@ -381,6 +582,76 @@ These captures are recordings, not automated reference-pixel assertions; pair
|
||||||
measurements are a separate check. Platform Roboto metrics and fixture ordering
|
measurements are a separate check. Platform Roboto metrics and fixture ordering
|
||||||
are not browser pixel parity.
|
are not browser pixel parity.
|
||||||
|
|
||||||
|
### Shared focus rings
|
||||||
|
|
||||||
|
The same `focusRing` stroke now covers pill/icon buttons (hero, title, state,
|
||||||
|
recovery and sheet actions), the nav avatar, picker people and gear. It is always
|
||||||
|
accent, 4 px / 2 dp wide, centred on the surface edge with no background gap.
|
||||||
|
The former outside-gap renderer is removed. The stroke draws after opaque
|
||||||
|
content, so neither half can be covered by the background or portrait.
|
||||||
|
|
||||||
|
Control tokens are `Focus.ringControlGap = 0` and `ringControlRing = 2.dp`;
|
||||||
|
avatar and picker tokens use the same geometry. Existing scales remain 1.05 for
|
||||||
|
buttons and 1.08 for picker people/gear; the nav avatar remains unscaled. Card
|
||||||
|
and button lifts remain; avatars, people and gear gain no shadow. Existing
|
||||||
|
clearance accommodates the smaller outside reach without layout movement.
|
||||||
|
|
||||||
|
Filled-only chips, nav pills, Settings/sheet/Coming-in rows and the A–Z rail
|
||||||
|
are unchanged. Selected-only chip/icon/radio borders and decorative borders
|
||||||
|
are not focus rings. These distinctions are explicit in the inventory and
|
||||||
|
reference states rather than silently adding rings to filled controls.
|
||||||
|
|
||||||
|
`FocusRingTest.ringStraddlesOpaqueSurfaceAndDisappearsWithoutFocus` checks the
|
||||||
|
two outside and two inside pixels against opaque content for pill, circle and
|
||||||
|
card radii, including clearing the stroke on blur. The focused hero Details,
|
||||||
|
nav avatar, picker, Discover chip, Library toolbar chip and title Request
|
||||||
|
captures exercise the actual screens at 1920×1080. Browser and native glyph
|
||||||
|
metrics differ; their ring offsets are measured relative to each surface edge.
|
||||||
|
|
||||||
|
### TV feedback corrections (spec revision 5)
|
||||||
|
|
||||||
|
The [revision-5 amendment](../data/tv-spec/spec.md) and
|
||||||
|
[native frame prototype delta](../data/tv-v2/feedback-2026-11-06/index.html)
|
||||||
|
retain the approved frames except for the requested feedback changes:
|
||||||
|
|
||||||
|
- The picker gear is centred under everyone and uses the picker-person ring.
|
||||||
|
- Shelf tracks share second-last-slot lookahead, count clamping and settled
|
||||||
|
offsets. Home records completed frame positioning rather than an animation
|
||||||
|
that may still be cancelled; first mount restores the intended shelf item.
|
||||||
|
Discover uses all six approved poster slots. Person keeps the final column
|
||||||
|
at full poster width.
|
||||||
|
- Episode images choose episode Primary, then Thumb, then the already-read
|
||||||
|
series Backdrop. Episode cards are 400×280 px; cast retains its 68 px clearance.
|
||||||
|
Episode context uses the episode overview, with two summary lines on the
|
||||||
|
focused card. Held-series headers measure their copy/actions and leave 40 px
|
||||||
|
before seasons instead of reserving a fixed empty 900 px header.
|
||||||
|
- Every Discover heading uses a 104 px band, including a 60 px title-to-card
|
||||||
|
gap. Home heading geometry is unchanged.
|
||||||
|
- Hero/backdrop decoding is bounded to 1920×1080; upright hero sheets request
|
||||||
|
420×630. The shared request builder replaces conflicting Jellyfin size/encoding
|
||||||
|
parameters: cards use WebP quality 80, while hero backdrops/sheets retain JPEG
|
||||||
|
quality 96. Poster/tile/wide/episode requests are 256×384, 400×225, 544×306 and
|
||||||
|
400×280; Jellyfin fill preserves aspect ratio before the UI crops. Tags and
|
||||||
|
authentication query parameters remain intact. TMDB uses w342 for posters and
|
||||||
|
portraits, w780 for landscape cards and w1280 for hero backdrops, never original.
|
||||||
|
Lossy WebP decoding is guaranteed below minSdk 23; API 28+ additionally checks
|
||||||
|
decoder support and requests JPEG if WebP is unavailable. The 64 MiB memory
|
||||||
|
cache, 128 MiB disk cache and four image-read limit remain unchanged.
|
||||||
|
|
||||||
|
`HomeViewportTest` covers short/long poster, tile and wide rows, both row
|
||||||
|
directions, first focus and native Back from the actual title surface. Native
|
||||||
|
proof records real Bear season-three metadata through the title read facade;
|
||||||
|
the proof-only harness is not part of the shipped tests or APK.
|
||||||
|
|
||||||
|
Episode lists request only ProviderIds and Overview. Next up reads only identity,
|
||||||
|
then hydrates the selected playback target through the existing single-item read,
|
||||||
|
including when Next up is empty. Media sources/streams and cast remain on detail
|
||||||
|
reads for header track/version controls. Performer credits omit media sources,
|
||||||
|
streams and ParentId but retain People because their cards show character roles.
|
||||||
|
Movies/Shows grids retain ProviderIds and DateCreated; server-side SortName
|
||||||
|
ordering does not require returning SortName on every item.
|
||||||
|
|
||||||
|
|
||||||
## Title pages and people
|
## Title pages and people
|
||||||
|
|
||||||
`TitleSurface` owns the film/series/episode header, season chips, episode tiles,
|
`TitleSurface` owns the film/series/episode header, season chips, episode tiles,
|
||||||
|
|
@ -400,6 +671,13 @@ Selections persist through the same playback repository as the upstream player.
|
||||||
Marking a whole series asks first; an explicit episode remains the exact play and
|
Marking a whole series asks first; an explicit episode remains the exact play and
|
||||||
watched target even while another season is selected. Nested person/title visits
|
watched target even while another season is selected. Nested person/title visits
|
||||||
retain their entry's focus and selection state.
|
retain their entry's focus and selection state.
|
||||||
|
Choosing another episode from an episode page replaces only the top navigation
|
||||||
|
entry, preserving its series/Home parent. Choosing the first episode from a
|
||||||
|
series, related titles, cast and performer credits still push normal drill-ins.
|
||||||
|
Down into season chips initially focuses the selected playable season, including
|
||||||
|
offscreen later seasons; the row scrolls that chip into view and remembers
|
||||||
|
subsequent deliberate browsing. Saved title watch data cannot enable Play/Resume
|
||||||
|
or Restart before source revalidation.
|
||||||
Hardware and remote Back share the sheet/picker/request-overlay precedence, then
|
Hardware and remote Back share the sheet/picker/request-overlay precedence, then
|
||||||
pop a pushed title/person from content **or** nav. Initial loading has no content
|
pop a pushed title/person from content **or** nav. Initial loading has no content
|
||||||
target: Down stays in nav, while Back can still leave the title. A missing rating
|
target: Down stays in nav, while Back can still leave the title. A missing rating
|
||||||
|
|
@ -686,6 +964,41 @@ whose parent directory already exists. All callers sharing a volume must use
|
||||||
the same lock path on the Docker host. Do not delete the file while builds are
|
the same lock path on the Docker host. Do not delete the file while builds are
|
||||||
running or waiting. The lock releases automatically when the invocation exits.
|
running or waiting. The lock releases automatically when the invocation exits.
|
||||||
|
|
||||||
|
## Persistent debug signing
|
||||||
|
|
||||||
|
`make apk-debug` and non-release `docker/gradle.sh` invocations (including
|
||||||
|
`:app:assembleDefaultOptimized`) use one host keystore, outside the checkout:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/root/.treehouse/firstmate-7bab20/2/firstmate/data/signing/debug.keystore
|
||||||
|
```
|
||||||
|
|
||||||
|
Set `WOLFSKIN_DEBUG_KEYSTORE` to another file in a dedicated directory outside
|
||||||
|
the checkout when building on another host, for example:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
WOLFSKIN_DEBUG_KEYSTORE="$HOME/.local/share/wolfskin-signing/debug.keystore" make apk-debug
|
||||||
|
```
|
||||||
|
|
||||||
|
The wrapper creates a missing key once using the build image's `keytool`: RSA
|
||||||
|
2048, 10,000-day validity, alias `androiddebugkey`, store/key passwords `android`,
|
||||||
|
and subject `CN=Android Debug`. The containing directory is mode `0700`, the
|
||||||
|
file mode `0600`. Existing keys are reused, never regenerated; generation errors
|
||||||
|
abort the build rather than falling back to an ephemeral container key.
|
||||||
|
The file is bind-mounted read-only at `/tmp/.android/debug.keystore`, with
|
||||||
|
`ANDROID_USER_HOME=/tmp/.android`, for every non-release Gradle container.
|
||||||
|
Keep and back up this file across clean builds, disposable worktrees, container
|
||||||
|
rebuilds and Gradle cache resets. Never commit it. The standard debug passwords
|
||||||
|
are public; this key is for local debug/optimized builds, not release signing.
|
||||||
|
|
||||||
|
**One-time reinstall:** the first build with the stable key still needs one
|
||||||
|
uninstall+reinstall on the box because the signer changes once; later ones
|
||||||
|
install in place with `adb install -r`, retaining sign-ins and settings.
|
||||||
|
The initial uninstall removes app data, so sign in again once after that switch.
|
||||||
|
Deleting/replacing the persistent key or choosing a different override changes
|
||||||
|
the signer again and requires another reinstall.
|
||||||
|
|
||||||
|
|
||||||
## Release credentials
|
## Release credentials
|
||||||
|
|
||||||
The default escrow is
|
The default escrow is
|
||||||
|
|
|
||||||
|
|
@ -35,5 +35,36 @@ if [[ $mode == release ]]; then
|
||||||
exec ./gradlew "$@"
|
exec ./gradlew "$@"
|
||||||
' bash "${gradle_arguments[@]}" "$@"
|
' bash "${gradle_arguments[@]}" "$@"
|
||||||
else
|
else
|
||||||
|
debug_keystore=$(realpath -m "${WOLFSKIN_DEBUG_KEYSTORE:-/root/.treehouse/firstmate-7bab20/2/firstmate/data/signing/debug.keystore}")
|
||||||
|
case "$debug_keystore" in
|
||||||
|
"$root"|"$root"/*)
|
||||||
|
printf 'Debug keystore must be outside the checkout: %s\n' "$debug_keystore" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
signing_directory=$(dirname "$debug_keystore")
|
||||||
|
mkdir -p "$signing_directory"
|
||||||
|
chmod 700 "$signing_directory"
|
||||||
|
if [[ ! -e $debug_keystore ]]; then
|
||||||
|
temporary_directory=$(mktemp -d "$signing_directory/.debug-keystore.XXXXXX")
|
||||||
|
trap 'rm -rf -- "$temporary_directory"' EXIT
|
||||||
|
if ! docker run --rm --platform linux/amd64 --user "$(id -u):$(id -g)" \
|
||||||
|
--mount "type=bind,src=$temporary_directory,dst=/run/signing" \
|
||||||
|
"$image" keytool -genkeypair -noprompt -keystore /run/signing/debug.keystore \
|
||||||
|
-storetype JKS -storepass android -keypass android -alias androiddebugkey \
|
||||||
|
-keyalg RSA -keysize 2048 -validity 10000 -dname "CN=Android Debug"; then
|
||||||
|
printf 'Failed to generate persistent debug keystore: %s\n' "$debug_keystore" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
chmod 600 "$temporary_directory/debug.keystore"
|
||||||
|
mv "$temporary_directory/debug.keystore" "$debug_keystore"
|
||||||
|
fi
|
||||||
|
if [[ ! -s $debug_keystore ]]; then
|
||||||
|
printf 'Missing or empty persistent debug keystore: %s\n' "$debug_keystore" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
chmod 600 "$debug_keystore"
|
||||||
|
arguments+=(--env ANDROID_USER_HOME=/tmp/.android
|
||||||
|
--mount "type=bind,src=$debug_keystore,dst=/tmp/.android/debug.keystore,readonly")
|
||||||
docker run "${arguments[@]}" "$image" ./gradlew "${gradle_arguments[@]}" "$@"
|
docker run "${arguments[@]}" "$image" ./gradlew "${gradle_arguments[@]}" "$@"
|
||||||
fi
|
fi
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
{
|
{
|
||||||
"discover": {
|
"discover": {
|
||||||
|
"shelfHeadingBand": {"dp": 52, "px": 104, "source": "feedback-2026-11-06: heading-to-card gap 60 px on every Discover lane"},
|
||||||
"sheetPaddingX": {
|
"sheetPaddingX": {
|
||||||
"$type": "dimension",
|
"$type": "dimension",
|
||||||
"px": 64,
|
"px": 64,
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ fun CatalogueRecoveryCard(retry: Boolean, focused: Boolean, onClick: () -> Unit,
|
||||||
val shape = RoundedCornerShape(WolfskinTokens.Radius.card)
|
val shape = RoundedCornerShape(WolfskinTokens.Radius.card)
|
||||||
Column(modifier.size(WolfskinTokens.Card.posterW, WolfskinTokens.Card.posterH)
|
Column(modifier.size(WolfskinTokens.Card.posterW, WolfskinTokens.Card.posterH)
|
||||||
.focusScale(focused, WolfskinTokens.Focus.scaleCard)
|
.focusScale(focused, WolfskinTokens.Focus.scaleCard)
|
||||||
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent, FocusLift.Card, straddle = true)
|
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card)
|
||||||
.background(C.surface, shape).border(G.borderWidth, C.border, shape).clickable(onClick = onClick).padding(G.tailPadding),
|
.background(C.surface, shape).border(G.borderWidth, C.border, shape).clickable(onClick = onClick).padding(G.tailPadding),
|
||||||
verticalArrangement = Arrangement.spacedBy(G.tailGap, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally) {
|
verticalArrangement = Arrangement.spacedBy(G.tailGap, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
Glyph(if (retry) "alert" else "down", if (retry) C.warn else C.accentText, Modifier.size(G.tailIcon))
|
Glyph(if (retry) "alert" else "down", if (retry) C.warn else C.accentText, Modifier.size(G.tailIcon))
|
||||||
|
|
|
||||||
|
|
@ -157,7 +157,8 @@ fun ComingInRow(item: ComingInRequest, viewer: ShellPerson, focused: Boolean, ex
|
||||||
Column(Modifier.weight(1f)) {
|
Column(Modifier.weight(1f)) {
|
||||||
Text(item.title, Modifier.comingInLineBox(T.titleLineHeight, T.titleBaseline),
|
Text(item.title, Modifier.comingInLineBox(T.titleLineHeight, T.titleBaseline),
|
||||||
style = ComingInTypography.listTitle, color = foreground, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
style = ComingInTypography.listTitle, color = foreground, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
Text("for ${item.recipient(viewer)} · by ${item.actor(viewer)}", Modifier.comingInLineBox(T.factsLineHeight, T.factsBaseline),
|
val actor = item.actor(viewer)
|
||||||
|
Text("for ${item.recipient(viewer)}" + (actor?.let { " · by $it" } ?: ""), Modifier.comingInLineBox(T.factsLineHeight, T.factsBaseline),
|
||||||
style = ComingInTypography.caption, color = secondary)
|
style = ComingInTypography.caption, color = secondary)
|
||||||
FlowRow(Modifier.padding(top = T.stageMargin).heightIn(min = T.stageLineHeight), horizontalArrangement = Arrangement.spacedBy(T.readinessGlyph)) {
|
FlowRow(Modifier.padding(top = T.stageMargin).heightIn(min = T.stageLineHeight), horizontalArrangement = Arrangement.spacedBy(T.readinessGlyph)) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.stageGap)) {
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.stageGap)) {
|
||||||
|
|
@ -168,14 +169,17 @@ fun ComingInRow(item: ComingInRequest, viewer: ShellPerson, focused: Boolean, ex
|
||||||
}
|
}
|
||||||
if (expanded) FlowRow(Modifier.padding(top = T.readinessMargin),
|
if (expanded) FlowRow(Modifier.padding(top = T.readinessMargin),
|
||||||
horizontalArrangement = Arrangement.spacedBy(T.readinessGapX), verticalArrangement = Arrangement.spacedBy(T.readinessGapY)) {
|
horizontalArrangement = Arrangement.spacedBy(T.readinessGapX), verticalArrangement = Arrangement.spacedBy(T.readinessGapY)) {
|
||||||
item.stage.readiness.forEachIndexed { index, word ->
|
item.readiness.forEachIndexed { index, word ->
|
||||||
Row {
|
Row {
|
||||||
val glyph = when (word) { "Done" -> "check"; "Failed" -> "alert"; "Not yet", "Unknown" -> null; else -> "clock" }
|
val glyph = when (word) { "Done", "Files present", "Recorded play", "Another library" -> "check"; "Failed", "Check needed", "Needs attention" -> "alert"; "Not yet", "Unknown", "Not checked yet", "Not in library", "Removed", "Not out yet" -> null; else -> "clock" }
|
||||||
if (glyph != null) Glyph(glyph, secondary, Modifier.size(T.readinessGlyph).alignBy { it.measuredHeight })
|
if (glyph != null) Glyph(glyph, secondary, Modifier.size(T.readinessGlyph).alignBy { it.measuredHeight })
|
||||||
Text("${READINESS_NAMES[index]} · $word", Modifier.alignByBaseline(), style = ComingInTypography.readiness, color = secondary)
|
Text("${READINESS_NAMES[index]} · $word", Modifier.alignByBaseline(), style = ComingInTypography.readiness, color = secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (expanded) item.summary?.let { summary ->
|
||||||
|
Text(summary, Modifier.padding(top = T.readinessMargin), style = ComingInTypography.readiness, color = secondary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (focused && !expanded) Glyph("right", foreground.copy(alpha = T.chevronAlpha), Modifier.size(T.chevron))
|
if (focused && !expanded) Glyph("right", foreground.copy(alpha = T.chevronAlpha), Modifier.size(T.chevron))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,14 +6,21 @@ import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
/** Household acquisition stages, ordered independently from their display wording. */
|
/** History states distinguish acquisition work from library evidence and playback health. */
|
||||||
enum class ComingInStage(val label: String, val rank: Int, val glyph: String, val readiness: List<String>) {
|
enum class ComingInStage(val label: String, val rank: Int, val glyph: String, val readiness: List<String>) {
|
||||||
Failed("Failed", 0, "alert", listOf("Done", "Failed", "Not yet", "Not yet")),
|
Failed("Failed", 0, "alert", listOf("Done", "Failed", "Not yet", "Not yet")),
|
||||||
|
FileHealth("File health needs attention", 0, "alert", listOf("Done", "Files present", "See details", "Needs attention")),
|
||||||
|
CheckNeeded("Check needed", 0, "alert", listOf("Done", "Files present", "Check needed", "Not checked yet")),
|
||||||
Downloading("Downloading", 1, "download", listOf("Done", "Downloading", "Not yet", "Not yet")),
|
Downloading("Downloading", 1, "download", listOf("Done", "Downloading", "Not yet", "Not yet")),
|
||||||
AlmostReady("Almost ready", 1, "download", listOf("Done", "Done", "Being added", "Not yet")),
|
AlmostReady("Almost ready", 1, "download", listOf("Done", "Done", "Being added", "Not yet")),
|
||||||
Checking("Checking", 2, "clock", listOf("Checking", "Unknown", "Not yet", "Not yet")),
|
Checking("Checking", 2, "clock", listOf("Checking", "Unknown", "Not yet", "Not yet")),
|
||||||
Searching("Searching", 2, "clock", listOf("Done", "Searching", "Not yet", "Not yet")),
|
Searching("Searching", 2, "clock", listOf("Done", "Searching", "Not yet", "Not yet")),
|
||||||
Waiting("Waiting", 3, "clock", listOf("Waiting", "Not yet", "Not yet", "Not yet")),
|
Waiting("Waiting", 3, "clock", listOf("Waiting", "Not yet", "Not yet", "Not yet")),
|
||||||
|
WaitingForFile("Waiting for a file", 3, "clock", listOf("Done", "Waiting for a file", "Not yet", "Not checked yet")),
|
||||||
|
NotOutYet("Not out yet", 3, "clock", listOf("Done", "Not out yet", "Not yet", "Not checked yet")),
|
||||||
|
HeldElsewhere("In another library", 4, "check", listOf("Done", "Files present", "Another library", "Not checked yet")),
|
||||||
|
Removed("Removed", 4, "clock", listOf("Done", "Removed", "Not in library", "Not checked yet")),
|
||||||
|
NotInLibrary("Not in any library", 4, "clock", listOf("Done", "Not observed", "Not in library", "Not checked yet")),
|
||||||
Ready("Ready", 4, "check", listOf("Done", "Done", "Done", "Not checked yet")),
|
Ready("Ready", 4, "check", listOf("Done", "Done", "Done", "Not checked yet")),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -33,13 +40,15 @@ data class ComingInRequest(
|
||||||
val stage: ComingInStage,
|
val stage: ComingInStage,
|
||||||
val poster: Any? = null,
|
val poster: Any? = null,
|
||||||
val ratings: List<ComingInRating> = emptyList(),
|
val ratings: List<ComingInRating> = emptyList(),
|
||||||
|
val summary: String? = null,
|
||||||
|
val readiness: List<String> = stage.readiness,
|
||||||
) {
|
) {
|
||||||
val ratingKey = "$mediaType:$tmdbId"
|
val ratingKey = "$mediaType:$tmdbId"
|
||||||
fun recipient(viewer: ShellPerson): String = if (requesterKey == viewer.key) "you" else requesterName
|
fun recipient(viewer: ShellPerson): String = if (requesterKey == viewer.key) "you" else requesterName
|
||||||
fun actor(viewer: ShellPerson): String = when {
|
fun actor(viewer: ShellPerson): String? = when {
|
||||||
actorKey == "shared" -> "Shared"
|
actorKey == "shared" -> "Shared"
|
||||||
actorKey != null && actorKey == viewer.key -> "you"
|
actorKey != null && actorKey == viewer.key -> "you"
|
||||||
else -> actorName ?: "Unknown"
|
else -> actorName?.takeIf { it.isNotBlank() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -54,7 +63,13 @@ data class ComingInSnapshot(
|
||||||
val savedAt: String? = null,
|
val savedAt: String? = null,
|
||||||
) {
|
) {
|
||||||
val ordered = items.sortedBy { it.stage.rank }
|
val ordered = items.sortedBy { it.stage.rank }
|
||||||
val openCount: Int get() = items.count { it.stage != ComingInStage.Ready }
|
val requestCount: Int? get() = if (status == ComingInStatus.Ready || items.isNotEmpty()) items.size else null
|
||||||
|
val hasUnsettledRequests: Boolean get() = items.any {
|
||||||
|
when (it.stage) {
|
||||||
|
ComingInStage.Ready, ComingInStage.Removed, ComingInStage.NotInLibrary, ComingInStage.HeldElsewhere, ComingInStage.NotOutYet -> false
|
||||||
|
else -> true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A bounded window and stable request id preserve focus across refreshes and title round trips. */
|
/** A bounded window and stable request id preserve focus across refreshes and title round trips. */
|
||||||
|
|
|
||||||
|
|
@ -50,8 +50,11 @@ fun DiscoverArtwork(path: String?, modifier: Modifier, poster: Boolean = false,
|
||||||
this *= ColorMatrix().apply { setToScale(D.ambientBrightness, D.ambientBrightness, D.ambientBrightness, 1f) }
|
this *= ColorMatrix().apply { setToScale(D.ambientBrightness, D.ambientBrightness, D.ambientBrightness, 1f) }
|
||||||
}) else null }
|
}) else null }
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val request = remember(context, path, poster) {
|
val request = remember(context, path, poster, dim) {
|
||||||
WolfskinImages.request(context, path, if (poster) 256 else 1280, if (poster) 384 else 720, poster)
|
val upright = poster || dim
|
||||||
|
WolfskinImages.request(context, path,
|
||||||
|
if (upright) WolfskinTokens.Image.heroPosterRequestWidth else WolfskinTokens.Image.heroRequestWidth,
|
||||||
|
if (upright) WolfskinTokens.Image.heroPosterRequestHeight else WolfskinTokens.Image.heroRequestHeight, upright)
|
||||||
}
|
}
|
||||||
if (painter != null) Image(painter, null, modifier, contentScale = ContentScale.Crop, colorFilter = filter)
|
if (painter != null) Image(painter, null, modifier, contentScale = ContentScale.Crop, colorFilter = filter)
|
||||||
else AsyncImage(
|
else AsyncImage(
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,8 @@ class DiscoverFixture(json: String, private val artPath: (String) -> String = {
|
||||||
val items = pool.filter { genre in normalizedGenres(it.genres) && (genre !in setOf("Science Fiction", "Horror", "Romance") || it.type == "movie") }
|
val items = pool.filter { genre in normalizedGenres(it.genres) && (genre !in setOf("Science Fiction", "Horror", "Romance") || it.type == "movie") }
|
||||||
DiscoverLane("genre-$genre", genre, items.take(20), items.size, fullTitles = items)
|
DiscoverLane("genre-$genre", genre, items.take(20), items.size, fullTitles = items)
|
||||||
}
|
}
|
||||||
return DiscoverSnapshot(picks, groups.values.map { DiscoverSeed(it.first, it.second.toList()) }, services, lanes, status = DiscoverStatus.Ready, reusesFixtureLanes = true)
|
return DiscoverSnapshot(picks, groups.map { (key, group) -> DiscoverSeed(key, group.first, group.second.toList(), watched = true, added = false) },
|
||||||
|
services, lanes, status = DiscoverStatus.Ready, reusesFixtureLanes = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun service(service: DiscoverService, type: String): List<DiscoverLane> {
|
fun service(service: DiscoverService, type: String): List<DiscoverLane> {
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,9 @@ internal fun projectDiscoverLanes(snapshot: DiscoverSnapshot, heroes: List<Disco
|
||||||
val result = mutableListOf<DiscoverLane>()
|
val result = mutableListOf<DiscoverLane>()
|
||||||
fun take(items: List<DiscoverTitle>, limit: Int) = items.filter { matches(it) && it.key !in hidden && it.key !in seen }.take(limit).filter { seen.add(it.key) }
|
fun take(items: List<DiscoverTitle>, limit: Int) = items.filter { matches(it) && it.key !in hidden && it.key !in seen }.take(limit).filter { seen.add(it.key) }
|
||||||
if (snapshot.status == DiscoverStatus.Ready) {
|
if (snapshot.status == DiscoverStatus.Ready) {
|
||||||
val groups = snapshot.seeds.map { seed -> seed.copy(items = seed.items.filter { matches(it) && it.key !in hidden }) }.filter { it.items.isNotEmpty() }.take(3)
|
val groups = snapshot.seeds.asSequence().filter { it.watched && it.title.isNotBlank() }
|
||||||
|
.map { seed -> seed.copy(items = seed.items.filter { matches(it) && it.key !in hidden }) }
|
||||||
|
.filter { it.items.isNotEmpty() }.take(3).toList()
|
||||||
for ((type, name) in listOf("movie" to "Movies", "tv" to "Series")) {
|
for ((type, name) in listOf("movie" to "Movies", "tv" to "Series")) {
|
||||||
val all = snapshot.picks.filter { it.type == type && matches(it) && it.key !in hidden }
|
val all = snapshot.picks.filter { it.type == type && matches(it) && it.key !in hidden }
|
||||||
val count = all.count { it.key !in seen }
|
val count = all.count { it.key !in seen }
|
||||||
|
|
@ -18,9 +20,9 @@ internal fun projectDiscoverLanes(snapshot: DiscoverSnapshot, heroes: List<Disco
|
||||||
if (items.isNotEmpty()) result += DiscoverLane("pk-$type", "$name for you", items, all.size, pick = true, fullTitles = all)
|
if (items.isNotEmpty()) result += DiscoverLane("pk-$type", "$name for you", items, all.size, pick = true, fullTitles = all)
|
||||||
}
|
}
|
||||||
val size = if (groups.isEmpty()) 0 else minOf(8, ceil(snapshot.picks.count { matches(it) && it.key !in seen && it.key !in hidden }.toDouble() / groups.size).toInt())
|
val size = if (groups.isEmpty()) 0 else minOf(8, ceil(snapshot.picks.count { matches(it) && it.key !in seen && it.key !in hidden }.toDouble() / groups.size).toInt())
|
||||||
groups.forEachIndexed { index, seed ->
|
groups.forEach { seed ->
|
||||||
val items = take(seed.items, size)
|
val items = take(seed.items, size)
|
||||||
if (items.size >= 3) result += DiscoverLane("pk-w$index", "Because you watched ${seed.title}", items, seed.items.size, pick = true, fullTitles = seed.items)
|
if (items.size >= 3) result += DiscoverLane("pk-w-${seed.key}", "Because you watched ${seed.title}", items, seed.items.size, pick = true, fullTitles = seed.items)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val displayed = heroes.mapTo(mutableSetOf()) { it.key }
|
val displayed = heroes.mapTo(mutableSetOf()) { it.key }
|
||||||
|
|
|
||||||
|
|
@ -26,8 +26,8 @@ data class DiscoverTitle(
|
||||||
/** Observed source marks keep labels separate from score values. */
|
/** Observed source marks keep labels separate from score values. */
|
||||||
data class DiscoverRating(val source: String, val score: Double, val label: String? = null)
|
data class DiscoverRating(val source: String, val score: Double, val label: String? = null)
|
||||||
|
|
||||||
/** A contribution group preserves feed order rather than assigning a visible rank. */
|
/** A typed seed identity and its contribution evidence retain wire order without inventing recency. */
|
||||||
data class DiscoverSeed(val title: String, val items: List<DiscoverTitle>)
|
data class DiscoverSeed(val key: String, val title: String, val items: List<DiscoverTitle>, val watched: Boolean, val added: Boolean)
|
||||||
|
|
||||||
/** Services carry their catalogue id and a text wordmark, never third-party logos. */
|
/** Services carry their catalogue id and a text wordmark, never third-party logos. */
|
||||||
data class DiscoverService(val id: Long, val name: String, val hue: Float)
|
data class DiscoverService(val id: Long, val name: String, val hue: Float)
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,7 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
|
||||||
add(if (state.hasCatalogue) L.row_chips else WolfskinTokens.Shell.zero)
|
add(if (state.hasCatalogue) L.row_chips else WolfskinTokens.Shell.zero)
|
||||||
if (!state.hasCatalogue) add(L.row_state) else {
|
if (!state.hasCatalogue) add(L.row_state) else {
|
||||||
if (note) add(L.row_note)
|
if (note) add(L.row_note)
|
||||||
state.lanes.forEach { lane -> add(L.rowHeadingBand + (if (lane.id == "svc") WolfskinTokens.Card.entryH else WolfskinTokens.Card.posterH + WolfskinTokens.Card.posterCaptionBand) + L.rowGapAfter) }
|
state.lanes.forEach { lane -> add(D.shelfHeadingBand + (if (lane.id == "svc") WolfskinTokens.Card.entryH else WolfskinTokens.Card.posterH + WolfskinTokens.Card.posterCaptionBand) + L.rowGapAfter) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -197,7 +197,7 @@ private fun DiscoverStatePanel(state: DiscoverState) {
|
||||||
@Composable
|
@Composable
|
||||||
fun DiscoverButton(label: String, focused: Boolean, glyph: String? = null, primary: Boolean = true, onClick: () -> Unit) {
|
fun DiscoverButton(label: String, focused: Boolean, glyph: String? = null, primary: Boolean = true, onClick: () -> Unit) {
|
||||||
Row(Modifier.height(L.buttonHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl)
|
Row(Modifier.height(L.buttonHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl)
|
||||||
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringButtonGap, WolfskinTokens.Focus.ringButtonRing, C.accent, FocusLift.Button)
|
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringControlRing, FocusLift.Button)
|
||||||
.background(if (primary) C.fg else if (focused) C.buttonSecondaryFocused else C.buttonSecondary, RoundedCornerShape(WolfskinTokens.Radius.button))
|
.background(if (primary) C.fg else if (focused) C.buttonSecondaryFocused else C.buttonSecondary, RoundedCornerShape(WolfskinTokens.Radius.button))
|
||||||
.clickable(onClick = onClick).padding(horizontal = L.buttonPaddingX), horizontalArrangement = Arrangement.spacedBy(L.badgeInset), verticalAlignment = Alignment.CenterVertically) {
|
.clickable(onClick = onClick).padding(horizontal = L.buttonPaddingX), horizontalArrangement = Arrangement.spacedBy(L.badgeInset), verticalAlignment = Alignment.CenterVertically) {
|
||||||
if (glyph != null) Glyph(glyph, if (primary) C.onFocus else C.fg, Modifier.size(L.buttonIcon))
|
if (glyph != null) Glyph(glyph, if (primary) C.onFocus else C.fg, Modifier.size(L.buttonIcon))
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,6 @@
|
||||||
// SPDX-License-Identifier: GPL-2.0-only
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
package org.justinvisser.wolfskin.design
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.animation.core.CubicBezierEasing
|
|
||||||
import androidx.compose.foundation.gestures.animateScrollBy
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
|
@ -22,15 +19,13 @@ import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.zIndex
|
import androidx.compose.ui.zIndex
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Card as K
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Card as K
|
||||||
|
|
@ -47,30 +42,23 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
|
||||||
val count = if (services) state.snapshot.services.size else lane.titles.size + if (lane.tail == DiscoverTail.None) 0 else 1
|
val count = if (services) state.snapshot.services.size else lane.titles.size + if (lane.tail == DiscoverTail.None) 0 else 1
|
||||||
val column = (state.shell.view.cards[lane.id] ?: 0).coerceIn(0, (count - 1).coerceAtLeast(0))
|
val column = (state.shell.view.cards[lane.id] ?: 0).coerceIn(0, (count - 1).coerceAtLeast(0))
|
||||||
val active = state.contentFocused && state.focusedRow == row
|
val active = state.contentFocused && state.focusedRow == row
|
||||||
val slots = ((L.contentWidth + K.posterGap) / K.posterPitch).toInt()
|
val slots = K.posterSlots
|
||||||
val first = state.shell.view.cards["first:${lane.id}"] ?: 0
|
val first = state.shell.view.cards["first:${lane.id}"] ?: 0
|
||||||
var initialized by remember { mutableStateOf(false) }
|
val density = LocalDensity.current
|
||||||
LaunchedEffect(column, count) {
|
LaunchedEffect(column, count, active) {
|
||||||
var target = first
|
val target = shelfFirst(column, first, slots, count)
|
||||||
if (column > target + slots - 2) target = column - (slots - 2)
|
|
||||||
if (column < target + 1) target = (column - 1).coerceAtLeast(0)
|
|
||||||
target = target.coerceIn(0, (count - slots).coerceAtLeast(0))
|
|
||||||
state.shell.view.cards["first:${lane.id}"] = target
|
state.shell.view.cards["first:${lane.id}"] = target
|
||||||
val offset = cards.layoutInfo.visibleItemsInfo.firstOrNull { it.index == target }?.offset
|
cards.scrollShelfTo(target, with(density) { K.posterPitch.toPx() })
|
||||||
if (initialized && offset != null) {
|
|
||||||
if (offset != 0) cards.animateScrollBy(offset.toFloat(), tween(WolfskinTokens.Motion.base, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)))
|
|
||||||
} else cards.scrollToItem(target)
|
|
||||||
initialized = true
|
|
||||||
}
|
}
|
||||||
Box(Modifier.fillMaxWidth().height(L.rowHeadingBand + height + (if (services) WolfskinTokens.Shell.zero else K.posterCaptionBand) + L.rowGapAfter).testTag("discover-lane-${lane.id}")) {
|
Box(Modifier.fillMaxWidth().height(D.shelfHeadingBand + height + (if (services) WolfskinTokens.Shell.zero else K.posterCaptionBand) + L.rowGapAfter).testTag("discover-lane-${lane.id}")) {
|
||||||
Text(lane.title, Modifier.offset(L.safeSide), color = C.fg, style = DiscoverType.rowHeading, maxLines = 1)
|
Text(lane.title, Modifier.offset(L.safeSide), color = C.fg, style = DiscoverType.rowHeading, maxLines = 1)
|
||||||
LazyRow(state = cards, userScrollEnabled = false,
|
LazyRow(state = cards, userScrollEnabled = false,
|
||||||
contentPadding = PaddingValues(start = L.safeSide, end = WolfskinTokens.Shell.stageWidth - L.safeSide - (K.posterW * slots + K.posterGap * (slots - 1)), top = K.posterGap, bottom = K.posterGap),
|
contentPadding = PaddingValues(start = L.safeSide, end = WolfskinTokens.Shell.stageWidth - L.safeSide - (K.posterW * slots + K.posterGap * (slots - 1)), top = K.posterGap, bottom = K.posterGap),
|
||||||
horizontalArrangement = Arrangement.spacedBy(K.posterGap),
|
horizontalArrangement = Arrangement.spacedBy(K.posterGap),
|
||||||
modifier = Modifier.offset(y = L.rowHeadingBand - K.posterGap).height(height + K.posterGap * 2)) {
|
modifier = Modifier.offset(y = D.shelfHeadingBand - K.posterGap).height(height + K.posterGap * 2)) {
|
||||||
if (services) itemsIndexed(state.snapshot.services, key = { _, service -> "service:${service.id}" }, contentType = { _, _ -> "entry" }) { index, service ->
|
if (services) itemsIndexed(state.snapshot.services, key = { _, service -> "service:${service.id}" }, contentType = { _, _ -> "entry" }) { index, service ->
|
||||||
Box(Modifier.size(K.entryW, K.entryH).focusScale(active && index == column, WolfskinTokens.Focus.scaleCard)
|
Box(Modifier.size(K.entryW, K.entryH).focusScale(active && index == column, WolfskinTokens.Focus.scaleCard)
|
||||||
.focusRing(active && index == column, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent, FocusLift.Card, straddle = true)
|
.focusRing(active && index == column, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card)
|
||||||
.background(Brush.linearGradient(listOf(Color.hsl(service.hue, .30f, .26f), Color.hsl(service.hue, .30f, .12f))), RoundedCornerShape(WolfskinTokens.Radius.card))
|
.background(Brush.linearGradient(listOf(Color.hsl(service.hue, .30f, .26f), Color.hsl(service.hue, .30f, .12f))), RoundedCornerShape(WolfskinTokens.Radius.card))
|
||||||
.border(WolfskinTokens.Library.borderWidth, C.border.copy(alpha = D.entryBorderOpacity), RoundedCornerShape(WolfskinTokens.Radius.card))
|
.border(WolfskinTokens.Library.borderWidth, C.border.copy(alpha = D.entryBorderOpacity), RoundedCornerShape(WolfskinTokens.Radius.card))
|
||||||
.clickable { state.focus(row, index); state.openService(service) }, contentAlignment = Alignment.BottomStart) {
|
.clickable { state.focus(row, index); state.openService(service) }, contentAlignment = Alignment.BottomStart) {
|
||||||
|
|
@ -97,7 +85,7 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
|
||||||
Caption(
|
Caption(
|
||||||
item?.title ?: if (lane.tail == DiscoverTail.SeeAll) "See all ${lane.title}" else if (lane.tail == DiscoverTail.Retry) "Try again" else "Show more",
|
item?.title ?: if (lane.tail == DiscoverTail.SeeAll) "See all ${lane.title}" else if (lane.tail == DiscoverTail.Retry) "Try again" else "Show more",
|
||||||
if (item != null) buildDiscoverCaptionFacts(item, state, lane.hidden) else if (lane.tail == DiscoverTail.SeeAll) "${lane.total} titles" else if (lane.tail == DiscoverTail.Retry) "${lane.title} stopped loading" else "The last read only repeated titles",
|
if (item != null) buildDiscoverCaptionFacts(item, state, lane.hidden) else if (lane.tail == DiscoverTail.SeeAll) "${lane.total} titles" else if (lane.tail == DiscoverTail.Retry) "${lane.title} stopped loading" else "The last read only repeated titles",
|
||||||
true, Modifier.offset(L.safeSide + K.posterPitch * (column - first), L.rowHeadingBand + K.posterH + L.captionOffset).width(WolfskinTokens.Library.captionWidth),
|
true, Modifier.offset(L.safeSide + K.posterPitch * (column - first), D.shelfHeadingBand + K.posterH + L.captionOffset).width(WolfskinTokens.Library.captionWidth),
|
||||||
accentFirstFact = lane.hidden,
|
accentFirstFact = lane.hidden,
|
||||||
accentLastFact = item != null && (item.heldBy.isNotEmpty() || state.requests[item.key].orEmpty().isNotEmpty()),
|
accentLastFact = item != null && (item.heldBy.isNotEmpty() || state.requests[item.key].orEmpty().isNotEmpty()),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,9 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.ui.draw.dropShadow
|
import androidx.compose.ui.draw.dropShadow
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.drawBehind
|
|
||||||
import androidx.compose.ui.draw.drawWithCache
|
import androidx.compose.ui.draw.drawWithCache
|
||||||
import androidx.compose.ui.geometry.CornerRadius
|
import androidx.compose.ui.geometry.CornerRadius
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.geometry.Size
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
|
@ -36,12 +34,12 @@ fun Modifier.focusScale(focused: Boolean, scale: Float): Modifier {
|
||||||
return graphicsLayer { scaleX = animated; scaleY = animated }
|
return graphicsLayer { scaleX = animated; scaleY = animated }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Content lifts are distinct from the white person/gear rings. */
|
/** Lift shadows remain specific to cards and buttons. */
|
||||||
enum class FocusLift { None, Card, Button }
|
enum class FocusLift { None, Card, Button }
|
||||||
|
|
||||||
/** Card rings straddle the art edge above content; control rings retain their outside gap. */
|
/** Every focus ring straddles the surface edge above content, without a background gap. */
|
||||||
@Composable
|
@Composable
|
||||||
fun Modifier.focusRing(focused: Boolean, radius: Dp, gap: Dp, width: Dp, color: Color, lift: FocusLift = FocusLift.None, straddle: Boolean = false): Modifier {
|
fun Modifier.focusRing(focused: Boolean, radius: Dp, width: Dp, lift: FocusLift = FocusLift.None): Modifier {
|
||||||
val ringAlpha by animateFloatAsState(if (focused) 1f else 0f,
|
val ringAlpha by animateFloatAsState(if (focused) 1f else 0f,
|
||||||
tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)), label = "focus-ring")
|
tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)), label = "focus-ring")
|
||||||
val shadow = if (lift == FocusLift.None) this else {
|
val shadow = if (lift == FocusLift.None) this else {
|
||||||
|
|
@ -56,25 +54,17 @@ fun Modifier.focusRing(focused: Boolean, radius: Dp, gap: Dp, width: Dp, color:
|
||||||
alpha = ringAlpha * if (lift == FocusLift.Card) WolfskinTokens.Lift.cardOpacity else WolfskinTokens.Lift.buttonOpacity
|
alpha = ringAlpha * if (lift == FocusLift.Card) WolfskinTokens.Lift.cardOpacity else WolfskinTokens.Lift.buttonOpacity
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (straddle) return shadow.drawWithCache {
|
return shadow.drawWithCache {
|
||||||
val stroke = Stroke(width.toPx())
|
val stroke = Stroke(width.toPx())
|
||||||
val corner = CornerRadius(radius.toPx())
|
val corner = CornerRadius(radius.toPx())
|
||||||
onDrawWithContent {
|
onDrawWithContent {
|
||||||
drawContent()
|
drawContent()
|
||||||
if (ringAlpha > 0f) {
|
if (ringAlpha > 0f) {
|
||||||
drawRoundRect(color.copy(alpha = color.alpha * ringAlpha),
|
drawRoundRect(WolfskinTokens.Color.accent.copy(alpha = ringAlpha),
|
||||||
cornerRadius = corner, style = stroke)
|
cornerRadius = corner, style = stroke)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return shadow.drawBehind {
|
|
||||||
if (ringAlpha > 0f) {
|
|
||||||
val outer = (gap + width).toPx()
|
|
||||||
val inner = gap.toPx()
|
|
||||||
drawRoundRect(color.copy(alpha = color.alpha * ringAlpha), Offset(-outer, -outer), Size(size.width + 2 * outer, size.height + 2 * outer), CornerRadius(radius.toPx() + outer))
|
|
||||||
drawRoundRect(WolfskinTokens.Color.bg, Offset(-inner, -inner), Size(size.width + 2 * inner, size.height + 2 * inner), CornerRadius(radius.toPx() + inner))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pointing changes the same logical focus as the D-pad without activating an action. */
|
/** Pointing changes the same logical focus as the D-pad without activating an action. */
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ class HomeFocus(
|
||||||
private fun activate(row: HomeRow) {
|
private fun activate(row: HomeRow) {
|
||||||
when (row.kind) {
|
when (row.kind) {
|
||||||
HomeRowKind.Hero -> row.cards.firstOrNull()?.let { title ->
|
HomeRowKind.Hero -> row.cards.firstOrNull()?.let { title ->
|
||||||
onAction(if (column == 0 && row.heroKind != "pick") HomeAction.Play(title, row.heroKind == "resume") else HomeAction.Details(title))
|
onAction(if (column == 0 && row.heroKind != "pick" && row.watchReady) HomeAction.Play(title) else HomeAction.Details(title))
|
||||||
}
|
}
|
||||||
HomeRowKind.Shelf -> if (column < row.cards.size) onAction(HomeAction.Details(row.cards[column])) else onAction(HomeAction.SeeAll(row))
|
HomeRowKind.Shelf -> if (column < row.cards.size) onAction(HomeAction.Details(row.cards[column])) else onAction(HomeAction.SeeAll(row))
|
||||||
HomeRowKind.State -> onAction(HomeAction.Retry)
|
HomeRowKind.State -> onAction(HomeAction.Retry)
|
||||||
|
|
@ -107,9 +107,5 @@ internal val HomeRow.focusSlots: Int get() = when (cardKind) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Keep one card behind focus, but do not shift again while focus stays in the visible middle. */
|
/** Keep one card behind focus, but do not shift again while focus stays in the visible middle. */
|
||||||
internal fun HomeRow.adjustedFirst(column: Int, previous: Int): Int {
|
internal fun HomeRow.adjustedFirst(column: Int, previous: Int): Int =
|
||||||
var first = previous
|
shelfFirst(column, previous, focusSlots, count)
|
||||||
if (column > first + focusSlots - 2) first = column - (focusSlots - 2)
|
|
||||||
if (column < first + 1) first = (column - 1).coerceAtLeast(0)
|
|
||||||
return first.coerceIn(0, (count - focusSlots).coerceAtLeast(0))
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -43,9 +43,10 @@ fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List<HomeRati
|
||||||
Text(title.overview, color = C.muted, style = T.body, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.width(L.heroOverviewMax).exactLineBox(T.body))
|
Text(title.overview, color = C.muted, style = T.body, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.width(L.heroOverviewMax).exactLineBox(T.body))
|
||||||
Spacer(Modifier.height(H.overviewBottom))
|
Spacer(Modifier.height(H.overviewBottom))
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||||
val primary = if (row.heroKind == "pick") "Details" else (if (row.heroKind == "resume") "Resume" else "Play") + if (title.isEpisode) " ${title.episodeLabel}" else ""
|
val detailsOnly = row.heroKind == "pick" || !row.watchReady
|
||||||
PickerButton(primary, focused && column == 0, true, if (row.heroKind == "pick") "about" else "playback", onFocus = { onHover(0) }, textStyle = T.button) { onButton(0) }
|
val primary = if (detailsOnly) "Details" else (if (row.heroKind == "resume") "Resume" else "Play") + if (title.isEpisode) " ${title.episodeLabel}" else ""
|
||||||
if (row.heroKind != "pick") PickerButton("Details", focused && column == 1, false, "about", onFocus = { onHover(1) }, textStyle = T.button) { onButton(1) }
|
PickerButton(primary, focused && column == 0, true, if (detailsOnly) "about" else "playback", onFocus = { onHover(0) }, textStyle = T.button) { onButton(0) }
|
||||||
|
if (!detailsOnly) PickerButton("Details", focused && column == 1, false, "about", onFocus = { onHover(1) }, textStyle = T.button) { onButton(1) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -67,9 +67,10 @@ data class HomeRow(
|
||||||
val skeleton: Boolean = false,
|
val skeleton: Boolean = false,
|
||||||
val calm: Boolean = false,
|
val calm: Boolean = false,
|
||||||
val allCards: List<HomeTitle> = cards,
|
val allCards: List<HomeTitle> = cards,
|
||||||
|
val watchReady: Boolean = true,
|
||||||
) {
|
) {
|
||||||
val hasSeeAll: Boolean get() = total > cards.size
|
val hasSeeAll: Boolean get() = total > cards.size
|
||||||
val count: Int get() = when (kind) { HomeRowKind.Hero -> if (heroKind == "pick") 1 else 2; HomeRowKind.Shelf -> cards.size + if (hasSeeAll) 1 else 0; HomeRowKind.State, HomeRowKind.Explore -> 1; else -> 0 }
|
val count: Int get() = when (kind) { HomeRowKind.Hero -> if (heroKind == "pick" || !watchReady) 1 else 2; HomeRowKind.Shelf -> cards.size + if (hasSeeAll) 1 else 0; HomeRowKind.State, HomeRowKind.Explore -> 1; else -> 0 }
|
||||||
val focusable: Boolean get() = !skeleton && count > 0
|
val focusable: Boolean get() = !skeleton && count > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -84,7 +85,7 @@ data class HomeSnapshot(
|
||||||
|
|
||||||
/** Home emits integration intents, never owns the upstream player or later-slice pages. */
|
/** Home emits integration intents, never owns the upstream player or later-slice pages. */
|
||||||
sealed interface HomeAction {
|
sealed interface HomeAction {
|
||||||
data class Play(val title: HomeTitle, val resume: Boolean) : HomeAction
|
data class Play(val title: HomeTitle) : HomeAction
|
||||||
data class Details(val title: HomeTitle) : HomeAction
|
data class Details(val title: HomeTitle) : HomeAction
|
||||||
data class SeeAll(val row: HomeRow) : HomeAction
|
data class SeeAll(val row: HomeRow) : HomeAction
|
||||||
data object Discover : HomeAction
|
data object Discover : HomeAction
|
||||||
|
|
|
||||||
|
|
@ -61,25 +61,17 @@ fun HomeScreen(
|
||||||
LaunchedEffect(view.row, snapshot.rows) {
|
LaunchedEffect(view.row, snapshot.rows) {
|
||||||
snapshot.rows.drop(view.row + 1).firstOrNull { it.kind == HomeRowKind.Shelf && !it.skeleton }?.let { WolfskinImages.prefetch(context, it) }
|
snapshot.rows.drop(view.row + 1).firstOrNull { it.kind == HomeRowKind.Shelf && !it.skeleton }?.let { WolfskinImages.prefetch(context, it) }
|
||||||
}
|
}
|
||||||
LaunchedEffect(view) {
|
LaunchedEffect(view, view.row, view.home.resetScroll, heights) {
|
||||||
if (view.home.positionedKey != null) {
|
|
||||||
view.home.focusedKey?.let { view.home.tracks[it] }?.let { track ->
|
|
||||||
track.scrollToItem(track.firstVisibleItemIndex, track.firstVisibleItemScrollOffset)
|
|
||||||
}
|
|
||||||
view.listState.scrollToItem(view.listState.firstVisibleItemIndex, view.listState.firstVisibleItemScrollOffset)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
LaunchedEffect(view, view.row, view.home.resetScroll, snapshot.rows) {
|
|
||||||
val key = snapshot.rows.getOrNull(view.row)?.id
|
val key = snapshot.rows.getOrNull(view.row)?.id
|
||||||
if (view.home.positionedKey == key && view.home.positionedReset == view.home.resetScroll) return@LaunchedEffect
|
if (view.home.positionedKey == key && view.home.positionedReset == view.home.resetScroll) return@LaunchedEffect
|
||||||
val topRequested = shell.navFocused && view.home.resetScroll > 0 && view.home.positionedReset != view.home.resetScroll
|
val topRequested = shell.navFocused && view.home.resetScroll > 0 && view.home.positionedReset != view.home.resetScroll
|
||||||
val target = if (topRequested) 0f else with(density) {
|
val target = if (topRequested) 0f else with(density) {
|
||||||
(heights.take(view.row).fold(topInset) { total, height -> total + height } - if (snapshot.rows.getOrNull(view.row)?.kind == HomeRowKind.Hero) WolfskinTokens.Shell.zero else L.pinnedLine).toPx()
|
(heights.take(view.row).fold(topInset) { total, height -> total + height } - if (snapshot.rows.getOrNull(view.row)?.kind == HomeRowKind.Hero) WolfskinTokens.Shell.zero else L.pinnedLine).toPx()
|
||||||
}.coerceAtLeast(0f)
|
}.coerceAtLeast(0f)
|
||||||
view.home.positionedKey = key
|
|
||||||
view.home.positionedReset = view.home.resetScroll
|
|
||||||
val current = with(density) { heights.take(view.listState.firstVisibleItemIndex).fold(WolfskinTokens.Shell.zero) { total, height -> total + height }.toPx() } + view.listState.firstVisibleItemScrollOffset
|
val current = with(density) { heights.take(view.listState.firstVisibleItemIndex).fold(WolfskinTokens.Shell.zero) { total, height -> total + height }.toPx() } + view.listState.firstVisibleItemScrollOffset
|
||||||
view.listState.animateScrollBy(target - current, tween(WolfskinTokens.Motion.scroll, easing = CubicBezierEasing(.33f, 1f, .68f, 1f)))
|
view.listState.animateScrollBy(target - current, tween(WolfskinTokens.Motion.scroll, easing = CubicBezierEasing(.33f, 1f, .68f, 1f)))
|
||||||
|
view.home.positionedKey = key
|
||||||
|
view.home.positionedReset = view.home.resetScroll
|
||||||
}
|
}
|
||||||
val cache = view.home.ratings
|
val cache = view.home.ratings
|
||||||
val ratingsReader = LocalScreenRatings.current
|
val ratingsReader = LocalScreenRatings.current
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,6 @@ package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.gestures.animateScrollBy
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
|
@ -16,9 +14,8 @@ import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.SideEffect
|
import androidx.compose.runtime.SideEffect
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.snapshotFlow
|
import androidx.compose.runtime.setValue
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
|
@ -43,18 +40,12 @@ fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: In
|
||||||
val first = row.adjustedFirst(column, memory.firstCards[row.id] ?: 0)
|
val first = row.adjustedFirst(column, memory.firstCards[row.id] ?: 0)
|
||||||
SideEffect { if (memory.firstCards[row.id] != first) memory.firstCards[row.id] = first }
|
SideEffect { if (memory.firstCards[row.id] != first) memory.firstCards[row.id] = first }
|
||||||
val track = remember(memory, row.id) { memory.tracks.getOrPut(row.id) { LazyListState(first) } }
|
val track = remember(memory, row.id) { memory.tracks.getOrPut(row.id) { LazyListState(first) } }
|
||||||
val targetFirst by rememberUpdatedState(first)
|
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
LaunchedEffect(track) {
|
var positioned by remember(track) { mutableStateOf(false) }
|
||||||
track.scrollToItem(track.firstVisibleItemIndex, track.firstVisibleItemScrollOffset)
|
LaunchedEffect(track, first, focused, width, density) {
|
||||||
var positionedFirst = targetFirst
|
if (!positioned) track.scrollToItem(first)
|
||||||
snapshotFlow { targetFirst }.collectLatest { target ->
|
else track.scrollShelfTo(first, with(density) { (width + K.posterGap).toPx() })
|
||||||
if (target != positionedFirst) {
|
positioned = true
|
||||||
positionedFirst = target
|
|
||||||
val pitch = with(density) { (width + K.posterGap).toPx() }
|
|
||||||
track.animateScrollBy((target - track.firstVisibleItemIndex) * pitch - track.firstVisibleItemScrollOffset, tween(WolfskinTokens.Motion.base))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Box(Modifier.fillMaxWidth().height(L.rowHeadingBand + height + caption + L.rowGapAfter)) {
|
Box(Modifier.fillMaxWidth().height(L.rowHeadingBand + height + caption + L.rowGapAfter)) {
|
||||||
if (!row.skeleton) Text(row.title, color = C.fg, style = T.rowHeading, maxLines = 1, modifier = Modifier.offset(x = L.safeSide))
|
if (!row.skeleton) Text(row.title, color = C.fg, style = T.rowHeading, maxLines = 1, modifier = Modifier.offset(x = L.safeSide))
|
||||||
|
|
|
||||||
|
|
@ -111,7 +111,7 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier)
|
||||||
val avatar = boxes.last()
|
val avatar = boxes.last()
|
||||||
Avatar(state.person, L.navAvatar, WolfskinTokens.Type.avatarLetterNav,
|
Avatar(state.person, L.navAvatar, WolfskinTokens.Type.avatarLetterNav,
|
||||||
Modifier.offset(avatar.left, avatar.top)
|
Modifier.offset(avatar.left, avatar.top)
|
||||||
.focusRing(avatarFocused, L.navAvatar / 2, WolfskinTokens.Focus.ringAvatarGap, WolfskinTokens.Focus.ringAvatarRing, C.fg)
|
.focusRing(avatarFocused, L.navAvatar / 2, WolfskinTokens.Focus.ringAvatarRing)
|
||||||
.focusProperties { canFocus = false }
|
.focusProperties { canFocus = false }
|
||||||
.clickable { state.focusNav(state.tabs.size); state.key(ShellKey.Ok) })
|
.clickable { state.focusNav(state.tabs.size); state.key(ShellKey.Ok) })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -145,7 +145,7 @@ private fun PersonPage(state: PersonState, shell: ShellState, modifier: Modifier
|
||||||
Text("Filmography", color = C.fg, style = TitleTextStyles.rowHeading, modifier = Modifier.height(L.row_gh).padding(start = L.safeSide, top = D.gridHeadingTop))
|
Text("Filmography", color = C.fg, style = TitleTextStyles.rowHeading, modifier = Modifier.height(L.row_gh).padding(start = L.safeSide, top = D.gridHeadingTop))
|
||||||
}
|
}
|
||||||
itemsIndexed(state.credits.chunked(columns), key = { _, row -> row.first().id }, contentType = { _, _ -> "grid" }) { rowIndex, row ->
|
itemsIndexed(state.credits.chunked(columns), key = { _, row -> row.first().id }, contentType = { _, _ -> "grid" }) { rowIndex, row ->
|
||||||
Row(Modifier.height(D.gridHeight).padding(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) {
|
Row(Modifier.height(D.gridHeight).padding(start = L.safeSide), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) {
|
||||||
row.forEachIndexed { column, item ->
|
row.forEachIndexed { column, item ->
|
||||||
val index = rowIndex * columns + column
|
val index = rowIndex * columns + column
|
||||||
TitlePoster(item, index == state.index && !shell.navFocused, Modifier.width(WolfskinTokens.Card.posterW), catalogue = true, onFocus = { state.index = index }) { state.click(index) }
|
TitlePoster(item, index == state.index && !shell.navFocused, Modifier.width(WolfskinTokens.Card.posterW), catalogue = true, onFocus = { state.index = index }) { state.click(index) }
|
||||||
|
|
|
||||||
|
|
@ -52,9 +52,9 @@ val SETTINGS_GROUPS = listOf(
|
||||||
SettingsGroup("advanced", "Advanced settings", "Ratings display · Backdrop style · Playback backend (ExoPlayer, MPV) · Max resolution and bitrate · Skip segments · Logs and image cache"),
|
SettingsGroup("advanced", "Advanced settings", "Ratings display · Backdrop style · Playback backend (ExoPlayer, MPV) · Max resolution and bitrate · Skip segments · Logs and image cache"),
|
||||||
)
|
)
|
||||||
|
|
||||||
/** The household request count shares the sheet's snapshot and excludes Ready. */
|
/** The count describes returned request history, not an inferred queue of active downloads. */
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsList(state: ShellState, modifier: Modifier = Modifier, openRequests: Int? = null) {
|
fun SettingsList(state: ShellState, modifier: Modifier = Modifier, requestCount: Int? = null) {
|
||||||
val list = state.view.listState
|
val list = state.view.listState
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
LaunchedEffect(state.settingsRow, state.navFocused) {
|
LaunchedEffect(state.settingsRow, state.navFocused) {
|
||||||
|
|
@ -78,7 +78,7 @@ fun SettingsList(state: ShellState, modifier: Modifier = Modifier, openRequests:
|
||||||
itemsIndexed(SETTINGS_GROUPS, key = { _, group -> group.key }) { index, group ->
|
itemsIndexed(SETTINGS_GROUPS, key = { _, group -> group.key }) { index, group ->
|
||||||
Box(Modifier.height(L.row_srow)) {
|
Box(Modifier.height(L.row_srow)) {
|
||||||
SettingsRow(group, !state.navFocused && !state.requestsVisible && index == state.settingsRow,
|
SettingsRow(group, !state.navFocused && !state.requestsVisible && index == state.settingsRow,
|
||||||
if (group.key == "requests") openRequests else null,
|
if (group.key == "requests") requestCount else null,
|
||||||
Modifier.offset(L.safeSide, S.settingsRowTop)) {
|
Modifier.offset(L.safeSide, S.settingsRowTop)) {
|
||||||
state.navFocused = false; state.settingsRow = index; state.key(ShellKey.Ok)
|
state.navFocused = false; state.settingsRow = index; state.key(ShellKey.Ok)
|
||||||
}
|
}
|
||||||
|
|
@ -89,7 +89,7 @@ fun SettingsList(state: ShellState, modifier: Modifier = Modifier, openRequests:
|
||||||
|
|
||||||
/** The inverse focus fill has no focus ring and never changes row layout. */
|
/** The inverse focus fill has no focus ring and never changes row layout. */
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsRow(group: SettingsGroup, focused: Boolean, openRequests: Int?, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
fun SettingsRow(group: SettingsGroup, focused: Boolean, requestCount: Int?, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||||
val foreground = if (focused) C.onFocus else C.fg
|
val foreground = if (focused) C.onFocus else C.fg
|
||||||
Row(modifier.size(L.settingsRowWidth, L.settingsRowHeight).focusScale(focused, WolfskinTokens.Focus.scaleRow)
|
Row(modifier.size(L.settingsRowWidth, L.settingsRowHeight).focusScale(focused, WolfskinTokens.Focus.scaleRow)
|
||||||
.background(if (focused) C.fg else C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow))
|
.background(if (focused) C.fg else C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow))
|
||||||
|
|
@ -97,7 +97,7 @@ fun SettingsRow(group: SettingsGroup, focused: Boolean, openRequests: Int?, modi
|
||||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.settingsRowGap)) {
|
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.settingsRowGap)) {
|
||||||
Glyph(group.key, if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsIcon))
|
Glyph(group.key, if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsIcon))
|
||||||
Column(Modifier.weight(1f)) {
|
Column(Modifier.weight(1f)) {
|
||||||
val count = if (openRequests != null && openRequests > 0) " · $openRequests open" else ""
|
val count = requestCount?.let { " · $it ${if (it == 1) "request" else "requests"}" } ?: ""
|
||||||
Text(group.title + count, Modifier.exactLineBox(WolfskinTokens.Type.listTitle), color = foreground, style = WolfskinTokens.Type.listTitle, maxLines = 1)
|
Text(group.title + count, Modifier.exactLineBox(WolfskinTokens.Type.listTitle), color = foreground, style = WolfskinTokens.Type.listTitle, maxLines = 1)
|
||||||
Text(group.description, Modifier.exactLineBox(WolfskinTokens.Type.caption), color = if (focused) C.onFocus else C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
Text(group.description, Modifier.exactLineBox(WolfskinTokens.Type.caption), color = if (focused) C.onFocus else C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.CubicBezierEasing
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.gestures.animateScrollBy
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
|
||||||
|
/** Preserve one lookahead card and clamp short shelves and row ends to their real contents. */
|
||||||
|
internal fun shelfFirst(column: Int, previous: Int, slots: Int, count: Int): Int {
|
||||||
|
var first = previous
|
||||||
|
if (column > first + slots - 2) first = column - (slots - 2)
|
||||||
|
if (column < first + 1) first = (column - 1).coerceAtLeast(0)
|
||||||
|
return first.coerceIn(0, (count - slots).coerceAtLeast(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reconcile the actual track on every focus transition, including interrupted or restored offsets. */
|
||||||
|
internal suspend fun LazyListState.scrollShelfTo(first: Int, pitch: Float) {
|
||||||
|
val distance = (first - firstVisibleItemIndex) * pitch - firstVisibleItemScrollOffset
|
||||||
|
if (distance != 0f) animateScrollBy(distance, tween(WolfskinTokens.Motion.base,
|
||||||
|
easing = CubicBezierEasing(.25f, .1f, .25f, 1f)))
|
||||||
|
if (firstVisibleItemIndex != first || firstVisibleItemScrollOffset != 0) scrollToItem(first)
|
||||||
|
}
|
||||||
|
|
@ -55,7 +55,7 @@ fun ShellSurface(
|
||||||
state: ShellState,
|
state: ShellState,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
handlesContentKeys: Boolean = true,
|
handlesContentKeys: Boolean = true,
|
||||||
settingsOpenRequests: Int? = null,
|
settingsRequestCount: Int? = null,
|
||||||
overlay: @Composable () -> Unit = {},
|
overlay: @Composable () -> Unit = {},
|
||||||
content: @Composable (Modifier) -> Unit = { ShellPlaceholder(state.route) },
|
content: @Composable (Modifier) -> Unit = { ShellPlaceholder(state.route) },
|
||||||
) {
|
) {
|
||||||
|
|
@ -91,7 +91,7 @@ fun ShellSurface(
|
||||||
if (state.pickerVisible) {
|
if (state.pickerVisible) {
|
||||||
WhosWatching(state)
|
WhosWatching(state)
|
||||||
} else {
|
} else {
|
||||||
if (state.route == "settings") SettingsList(state, openRequests = settingsOpenRequests ?: state.requests.snapshot.openCount) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup())
|
if (state.route == "settings") SettingsList(state, requestCount = settingsRequestCount ?: state.requests.snapshot.requestCount) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup())
|
||||||
NavBar(state, state.view.listState.firstVisibleItemIndex > 0 || state.view.listState.firstVisibleItemScrollOffset > L.navSolidAfter.value * 2)
|
NavBar(state, state.view.listState.firstVisibleItemIndex > 0 || state.view.listState.firstVisibleItemScrollOffset > L.navSolidAfter.value * 2)
|
||||||
if (state.requestsVisible) ComingInSheet(state)
|
if (state.requestsVisible) ComingInSheet(state)
|
||||||
overlay()
|
overlay()
|
||||||
|
|
|
||||||
|
|
@ -98,14 +98,14 @@ fun TitleBackdrop(item: TitleItem, modifier: Modifier = Modifier) {
|
||||||
for (index in 0 until 15) matrix.values[index] *= filter[3].toFloat()
|
for (index in 0 until 15) matrix.values[index] *= filter[3].toFloat()
|
||||||
ColorFilter.colorMatrix(matrix)
|
ColorFilter.colorMatrix(matrix)
|
||||||
}
|
}
|
||||||
TitleArtwork(item.poster, WolfskinTokens.Image.heroRequestWidth, WolfskinTokens.Image.heroRequestHeight,
|
TitleArtwork(item.poster, WolfskinTokens.Image.heroPosterRequestWidth, WolfskinTokens.Image.heroPosterRequestHeight,
|
||||||
Modifier.fillMaxSize().artOverscan(D.ambientInset).graphicsLayer { scaleX = D.ambientScale; scaleY = D.ambientScale }
|
Modifier.fillMaxSize().artOverscan(D.ambientInset).graphicsLayer { scaleX = D.ambientScale; scaleY = D.ambientScale }
|
||||||
.blur(Dp(filter[1].toFloat() / 2)), colorFilter = colorFilter)
|
.blur(Dp(filter[1].toFloat() / 2)), colorFilter = colorFilter)
|
||||||
val posterShape = remember { RoundedCornerShape(WolfskinTokens.Radius.heroSheet) }
|
val posterShape = remember { RoundedCornerShape(WolfskinTokens.Radius.heroSheet) }
|
||||||
val posterShadow = remember { Shadow(radius = D.posterShadowRadius, offset = DpOffset.Zero.copy(y = D.posterShadowY), color = Color.Black.copy(alpha = D.posterShadowOpacity)) }
|
val posterShadow = remember { Shadow(radius = D.posterShadowRadius, offset = DpOffset.Zero.copy(y = D.posterShadowY), color = Color.Black.copy(alpha = D.posterShadowOpacity)) }
|
||||||
TitleArtwork(item.poster, (L.heroSheetWidth.value * 2).toInt(), (L.heroSheetHeight.value * 2).toInt(),
|
TitleArtwork(item.poster, (L.heroSheetWidth.value * 2).toInt(), (L.heroSheetHeight.value * 2).toInt(),
|
||||||
Modifier.offset(WolfskinTokens.Shell.stageWidth - L.heroSheetWidth - D.heroSheetRight, D.heroSheetTop)
|
Modifier.offset(WolfskinTokens.Shell.stageWidth - L.heroSheetWidth - D.heroSheetRight, D.heroSheetTop)
|
||||||
.size(L.heroSheetWidth, L.heroSheetHeight).dropShadow(posterShape, posterShadow).clip(posterShape))
|
.size(L.heroSheetWidth, L.heroSheetHeight).dropShadow(posterShape, posterShadow).clip(posterShape), poster = true)
|
||||||
}
|
}
|
||||||
Box(Modifier.fillMaxSize().background(horizontal))
|
Box(Modifier.fillMaxSize().background(horizontal))
|
||||||
Box(Modifier.fillMaxSize().background(vertical))
|
Box(Modifier.fillMaxSize().background(vertical))
|
||||||
|
|
@ -131,13 +131,13 @@ fun TitleTileArtwork(item: TitleItem) {
|
||||||
val filter = remember { ColorFilter.colorMatrix(ColorMatrix().apply {
|
val filter = remember { ColorFilter.colorMatrix(ColorMatrix().apply {
|
||||||
for (index in 0 until 15) values[index] *= D.tileAmbientBrightness
|
for (index in 0 until 15) values[index] *= D.tileAmbientBrightness
|
||||||
}) }
|
}) }
|
||||||
TitleArtwork(item.poster, WolfskinTokens.Image.tileRequestWidth, WolfskinTokens.Image.tileRequestHeight, Modifier.fillMaxSize().artOverscan(D.tileAmbientInset).blur(D.tileAmbientBlur), colorFilter = filter)
|
TitleArtwork(item.poster, WolfskinTokens.Image.episodeRequestWidth, WolfskinTokens.Image.episodeRequestHeight, Modifier.fillMaxSize().artOverscan(D.tileAmbientInset).blur(D.tileAmbientBlur), colorFilter = filter)
|
||||||
val height = WolfskinTokens.Card.tileH - D.tileSheetInset * 2
|
val height = D.episodeCardHeight - D.tileSheetInset * 2
|
||||||
val shape = remember { RoundedCornerShape(D.tileSheetRadius) }
|
val shape = remember { RoundedCornerShape(D.tileSheetRadius) }
|
||||||
val shadow = remember { Shadow(radius = D.tileShadowRadius, offset = DpOffset.Zero.copy(y = D.tileShadowY), color = Color.Black.copy(alpha = D.tileShadowOpacity)) }
|
val shadow = remember { Shadow(radius = D.tileShadowRadius, offset = DpOffset.Zero.copy(y = D.tileShadowY), color = Color.Black.copy(alpha = D.tileShadowOpacity)) }
|
||||||
TitleArtwork(item.poster, (height.value * 2 * 2 / 3).toInt(), (height.value * 2).toInt(),
|
TitleArtwork(item.poster, (height.value * 2 * 2 / 3).toInt(), (height.value * 2).toInt(),
|
||||||
Modifier.offset(D.tileSheetLeft, D.tileSheetInset).size(height * (2f / 3), height).dropShadow(shape, shadow).clip(shape), poster = true)
|
Modifier.offset(D.tileSheetLeft, D.tileSheetInset).size(height * (2f / 3), height).dropShadow(shape, shadow).clip(shape), poster = true)
|
||||||
} else TitleArtwork(item.backdrop, WolfskinTokens.Image.tileRequestWidth, WolfskinTokens.Image.tileRequestHeight, Modifier.fillMaxSize())
|
} else TitleArtwork(item.backdrop, WolfskinTokens.Image.episodeRequestWidth, WolfskinTokens.Image.episodeRequestHeight, Modifier.fillMaxSize())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ fun TitleCard(focused: Boolean, modifier: Modifier = Modifier, width: Dp = Wolfs
|
||||||
height: Dp = WolfskinTokens.Card.posterH, content: @Composable BoxScope.() -> Unit) {
|
height: Dp = WolfskinTokens.Card.posterH, content: @Composable BoxScope.() -> Unit) {
|
||||||
val shape = RoundedCornerShape(WolfskinTokens.Radius.card)
|
val shape = RoundedCornerShape(WolfskinTokens.Radius.card)
|
||||||
Box(modifier.size(width, height).focusScale(focused, WolfskinTokens.Focus.scaleCard)
|
Box(modifier.size(width, height).focusScale(focused, WolfskinTokens.Focus.scaleCard)
|
||||||
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, WolfskinTokens.Color.accent, FocusLift.Card, straddle = true)) {
|
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card)) {
|
||||||
Box(Modifier.fillMaxSize().clip(shape).background(WolfskinTokens.Color.surface2), content = content)
|
Box(Modifier.fillMaxSize().clip(shape).background(WolfskinTokens.Color.surface2), content = content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ fun TitleControl(option: TitleOption, focused: Boolean, modifier: Modifier = Mod
|
||||||
val dimensions = if (option.icon) Modifier.size(L.iconButton) else Modifier.height(L.buttonHeight).padding(horizontal = L.buttonPaddingX)
|
val dimensions = if (option.icon) Modifier.size(L.iconButton) else Modifier.height(L.buttonHeight).padding(horizontal = L.buttonPaddingX)
|
||||||
Row(modifier.testTag("title-option-${option.id}").titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleControl)
|
Row(modifier.testTag("title-option-${option.id}").titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleControl)
|
||||||
.semantics { if (option.icon) contentDescription = option.label; role = Role.Button; selected = option.on }
|
.semantics { if (option.icon) contentDescription = option.label; role = Role.Button; selected = option.on }
|
||||||
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringButtonGap, WolfskinTokens.Focus.ringButtonRing, C.accent, FocusLift.Button)
|
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringControlRing, FocusLift.Button)
|
||||||
.background(background, shape)
|
.background(background, shape)
|
||||||
.then(if (option.on && !focused) Modifier.border(D.radioBorder, C.accent, shape) else Modifier)
|
.then(if (option.on && !focused) Modifier.border(D.radioBorder, C.accent, shape) else Modifier)
|
||||||
.clickable { focusOwner?.invoke(); onClick() }.then(dimensions),
|
.clickable { focusOwner?.invoke(); onClick() }.then(dimensions),
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,11 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
|
||||||
fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifier) {
|
fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifier) {
|
||||||
if (state.data.status == TitleReadStatus.Loading) { TitleHeaderSkeleton(modifier); return }
|
if (state.data.status == TitleReadStatus.Loading) { TitleHeaderSkeleton(modifier); return }
|
||||||
val item = state.data.item
|
val item = state.data.item
|
||||||
|
val episode = when {
|
||||||
|
state.row == "episodes" -> state.data.episodes.getOrNull(state.column)
|
||||||
|
state.data.watchTarget.kind == "episode" -> state.data.watchTarget
|
||||||
|
else -> state.data.playTarget?.takeIf { it.kind == "episode" }
|
||||||
|
}
|
||||||
val type = if (item.kind == "movie") "Movie" else "Series"
|
val type = if (item.kind == "movie") "Movie" else "Series"
|
||||||
CssColumn(modifier.offset(L.safeSide, L.detailCopyTop + D.lineLift).width(L.detailCopyWidth)) {
|
CssColumn(modifier.offset(L.safeSide, L.detailCopyTop + D.lineLift).width(L.detailCopyWidth)) {
|
||||||
Row(Modifier.exactLineBox(TitleTextStyles.dLine, bottom = D.lineGap), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) {
|
Row(Modifier.exactLineBox(TitleTextStyles.dLine, bottom = D.lineGap), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
|
@ -63,7 +68,7 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie
|
||||||
Text("for ${state.data.requestFor ?: "you"}" + state.data.requestActor?.let { " · by $it" }.orEmpty(), color = C.muted, style = TitleTextStyles.dLine)
|
Text("for ${state.data.requestFor ?: "you"}" + state.data.requestActor?.let { " · by $it" }.orEmpty(), color = C.muted, style = TitleTextStyles.dLine)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text(item.overview, color = C.muted, style = TitleTextStyles.body, maxLines = 3, overflow = TextOverflow.Ellipsis, modifier = Modifier.exactLineBox(TitleTextStyles.body, bottom = D.overviewGap).testTag("title-overview"))
|
Text((episode ?: item).overview, color = C.muted, style = TitleTextStyles.body, maxLines = 3, overflow = TextOverflow.Ellipsis, modifier = Modifier.exactLineBox(TitleTextStyles.body, bottom = D.overviewGap).testTag("title-overview"))
|
||||||
if (!item.held && state.data.connection != TitleConnection.SignedIn) {
|
if (!item.held && state.data.connection != TitleConnection.SignedIn) {
|
||||||
Text(if (state.data.connection == TitleConnection.SignedOut) "Requests need this TV to be signed in to Conductarr." else "Requests need Conductarr, and it can’t be reached right now.",
|
Text(if (state.data.connection == TitleConnection.SignedOut) "Requests need this TV to be signed in to Conductarr." else "Requests need Conductarr, and it can’t be reached right now.",
|
||||||
color = C.warn, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body, bottom = D.overviewGap))
|
color = C.warn, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body, bottom = D.overviewGap))
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,7 @@ data class TitlePageData(
|
||||||
val episodesStatus: TitleReadStatus = TitleReadStatus.Ready,
|
val episodesStatus: TitleReadStatus = TitleReadStatus.Ready,
|
||||||
val requestsAllowed: Boolean = true,
|
val requestsAllowed: Boolean = true,
|
||||||
val requestFor: String? = null,
|
val requestFor: String? = null,
|
||||||
|
val watchReady: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Effects are handled by the app's authenticated facade and navigation layer. */
|
/** Effects are handled by the app's authenticated facade and navigation layer. */
|
||||||
|
|
@ -101,7 +102,7 @@ sealed interface TitleAction {
|
||||||
data class Watched(val item: TitleItem, val watched: Boolean) : TitleAction
|
data class Watched(val item: TitleItem, val watched: Boolean) : TitleAction
|
||||||
data class Trailer(val url: String) : TitleAction
|
data class Trailer(val url: String) : TitleAction
|
||||||
data class Person(val person: TitlePerson) : TitleAction
|
data class Person(val person: TitlePerson) : TitleAction
|
||||||
data class OpenTitle(val item: TitleItem) : TitleAction
|
data class OpenTitle(val item: TitleItem, val episodeSelection: Boolean = false) : TitleAction
|
||||||
data class Season(val number: Int) : TitleAction
|
data class Season(val number: Int) : TitleAction
|
||||||
data class Request(val item: TitleItem, val forSomeoneElse: Boolean) : TitleAction
|
data class Request(val item: TitleItem, val forSomeoneElse: Boolean) : TitleAction
|
||||||
data class Hide(val item: TitleItem, val hidden: Boolean) : TitleAction
|
data class Hide(val item: TitleItem, val hidden: Boolean) : TitleAction
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.layout.boundsInRoot
|
import androidx.compose.ui.layout.boundsInRoot
|
||||||
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
|
@ -90,6 +91,10 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
|
||||||
val list = shell.view.listState
|
val list = shell.view.listState
|
||||||
var wheelPending by remember { mutableStateOf(false) }
|
var wheelPending by remember { mutableStateOf(false) }
|
||||||
val focus = remember { FocusRequester() }
|
val focus = remember { FocusRequester() }
|
||||||
|
var measuredHeaderHeight by remember(state.data.item.id) { mutableStateOf(L.row_dhead) }
|
||||||
|
val compactSeries = state.data.status == TitleReadStatus.Ready && state.data.item.held &&
|
||||||
|
state.data.item.kind == "tv" && state.data.item.seasons > 0
|
||||||
|
val headerHeight = if (compactSeries) measuredHeaderHeight else L.row_dhead
|
||||||
LaunchedEffect(state.sheet, shell.navFocused, shell.pickerVisible) {
|
LaunchedEffect(state.sheet, shell.navFocused, shell.pickerVisible) {
|
||||||
if (state.sheet == null && !shell.navFocused && !shell.pickerVisible) focus.requestFocus()
|
if (state.sheet == null && !shell.navFocused && !shell.pickerVisible) focus.requestFocus()
|
||||||
}
|
}
|
||||||
|
|
@ -105,19 +110,19 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
|
||||||
if (state.data.related.isNotEmpty()) add("related")
|
if (state.data.related.isNotEmpty()) add("related")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val rowOffsets = remember(displayedRows, density) {
|
val rowOffsets = remember(displayedRows, density, headerHeight) {
|
||||||
FloatArray(displayedRows.size + 1).apply {
|
FloatArray(displayedRows.size + 1).apply {
|
||||||
displayedRows.forEachIndexed { index, row -> this[index + 1] = this[index] + with(density) { titleRowHeight(row).toPx() } }
|
displayedRows.forEachIndexed { index, row -> this[index + 1] = this[index] + with(density) { titleRowHeight(row, headerHeight).toPx() } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val position by remember(list, rowOffsets) { derivedStateOf {
|
val position by remember(list, rowOffsets) { derivedStateOf {
|
||||||
rowOffsets[list.firstVisibleItemIndex.coerceAtMost(rowOffsets.lastIndex)] + list.firstVisibleItemScrollOffset
|
rowOffsets[list.firstVisibleItemIndex.coerceAtMost(rowOffsets.lastIndex)] + list.firstVisibleItemScrollOffset
|
||||||
} }
|
} }
|
||||||
LaunchedEffect(state.row, shell.navFocused, state.data.status, displayedRows) {
|
LaunchedEffect(state.row, shell.navFocused, state.data.status, displayedRows, rowOffsets) {
|
||||||
if (state.data.status == TitleReadStatus.Loading) shell.focusNav(shell.currentTab)
|
if (state.data.status == TitleReadStatus.Loading) shell.focusNav(shell.currentTab)
|
||||||
val index = if (shell.navFocused) 0 else displayedRows.indexOf(state.row).coerceAtLeast(0)
|
val index = if (shell.navFocused) 0 else displayedRows.indexOf(state.row).coerceAtLeast(0)
|
||||||
val y = rowOffsets[index]
|
val y = rowOffsets[index]
|
||||||
val height = with(density) { titleRowHeight(state.row).toPx() }
|
val height = with(density) { titleRowHeight(state.row, headerHeight).toPx() }
|
||||||
val target = if (index == 0 || (y < with(density) { L.windowAhead.toPx() } && y + height <= with(density) { (WolfskinTokens.Shell.stageHeight - L.safeTopPicker).toPx() })) 0f
|
val target = if (index == 0 || (y < with(density) { L.windowAhead.toPx() } && y + height <= with(density) { (WolfskinTokens.Shell.stageHeight - L.safeTopPicker).toPx() })) 0f
|
||||||
else y - with(density) { L.pinnedLine.toPx() }
|
else y - with(density) { L.pinnedLine.toPx() }
|
||||||
if (target != position) list.animateScrollBy(target - position, titleFrameScrollSpec)
|
if (target != position) list.animateScrollBy(target - position, titleFrameScrollSpec)
|
||||||
|
|
@ -148,7 +153,11 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
|
||||||
displayedRows.forEach { id -> item(key = id, contentType = if (id == "header") "header" else if (id == "seasons") "chips" else "shelf") {
|
displayedRows.forEach { id -> item(key = id, contentType = if (id == "header") "header" else if (id == "seasons") "chips" else "shelf") {
|
||||||
val active = state.row == id && !shell.navFocused && state.sheet == null
|
val active = state.row == id && !shell.navFocused && state.sheet == null
|
||||||
when (id) {
|
when (id) {
|
||||||
"header" -> Box(Modifier.height(L.row_dhead)) { TitleHeader(state, active) }
|
"header" -> Box(Modifier.height(headerHeight)) {
|
||||||
|
TitleHeader(state, active, if (compactSeries) Modifier.onSizeChanged { size ->
|
||||||
|
measuredHeaderHeight = L.detailCopyTop + D.lineLift + with(density) { size.height.toDp() } + D.seriesHeaderGap
|
||||||
|
} else Modifier)
|
||||||
|
}
|
||||||
"seasons" -> TitleSeasons(state, active)
|
"seasons" -> TitleSeasons(state, active)
|
||||||
"episodes" -> TitleMediaShelf(state, true, active)
|
"episodes" -> TitleMediaShelf(state, true, active)
|
||||||
"cast" -> TitleCast(state, active)
|
"cast" -> TitleCast(state, active)
|
||||||
|
|
@ -156,16 +165,16 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
|
||||||
}
|
}
|
||||||
} }
|
} }
|
||||||
item(key = "end-padding", contentType = "spacer") {
|
item(key = "end-padding", contentType = "spacer") {
|
||||||
Spacer(Modifier.height((WolfskinTokens.Shell.stageHeight - L.pinnedLine - titleRowHeight(displayedRows.last())).coerceAtLeast(D.minimumEndPadding)))
|
Spacer(Modifier.height((WolfskinTokens.Shell.stageHeight - L.pinnedLine - titleRowHeight(displayedRows.last(), headerHeight)).coerceAtLeast(D.minimumEndPadding)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun titleRowHeight(row: String): Dp = when (row) {
|
private fun titleRowHeight(row: String, headerHeight: Dp): Dp = when (row) {
|
||||||
"header" -> L.row_dhead
|
"header" -> headerHeight
|
||||||
"seasons" -> L.row_chips
|
"seasons" -> L.row_chips
|
||||||
"episodes" -> L.rowHeadingBand + WolfskinTokens.Card.tileH + L.rowGapAfter
|
"episodes" -> L.rowHeadingBand + D.episodeCardHeight + L.rowGapAfter
|
||||||
"cast" -> D.castHeight
|
"cast" -> D.castHeight
|
||||||
else -> L.rowHeadingBand + WolfskinTokens.Card.posterH + WolfskinTokens.Card.posterCaptionBand + L.rowGapAfter
|
else -> L.rowHeadingBand + WolfskinTokens.Card.posterH + WolfskinTokens.Card.posterCaptionBand + L.rowGapAfter
|
||||||
}
|
}
|
||||||
|
|
@ -173,7 +182,12 @@ private fun titleRowHeight(row: String): Dp = when (row) {
|
||||||
@Composable
|
@Composable
|
||||||
private fun TitleSeasons(state: TitleState, active: Boolean) {
|
private fun TitleSeasons(state: TitleState, active: Boolean) {
|
||||||
val focusOwner = LocalTitleFocusOwner.current
|
val focusOwner = LocalTitleFocusOwner.current
|
||||||
androidx.compose.foundation.lazy.LazyRow(Modifier.height(L.row_chips).padding(top = D.chipTop), contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(D.chipGap)) {
|
val list = androidx.compose.foundation.lazy.rememberLazyListState()
|
||||||
|
val visibleChip = if (active) state.column else state.data.seasonNumbers.indexOf(state.season)
|
||||||
|
LaunchedEffect(visibleChip, state.data.seasonNumbers) {
|
||||||
|
if (visibleChip >= 0) list.animateScrollToItem(visibleChip)
|
||||||
|
}
|
||||||
|
androidx.compose.foundation.lazy.LazyRow(Modifier.height(L.row_chips).padding(top = D.chipTop), state = list, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(D.chipGap)) {
|
||||||
items(state.data.seasonNumbers.size, key = { "season-${state.data.seasonNumbers[it]}" }, contentType = { "chip" }) { index ->
|
items(state.data.seasonNumbers.size, key = { "season-${state.data.seasonNumbers[it]}" }, contentType = { "chip" }) { index ->
|
||||||
val number = state.data.seasonNumbers[index]
|
val number = state.data.seasonNumbers[index]
|
||||||
val focused = active && state.column == index
|
val focused = active && state.column == index
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ internal fun TitleRowPrefetch(data: TitlePageData, row: String?) {
|
||||||
queue(data.cast[index].portrait, WolfskinTokens.Image.castRequestWidth, WolfskinTokens.Image.castRequestHeight)
|
queue(data.cast[index].portrait, WolfskinTokens.Image.castRequestWidth, WolfskinTokens.Image.castRequestHeight)
|
||||||
"episodes" -> for (index in 0 until minOf(data.episodes.size, WolfskinTokens.Card.tileSlots)) {
|
"episodes" -> for (index in 0 until minOf(data.episodes.size, WolfskinTokens.Card.tileSlots)) {
|
||||||
val item = data.episodes[index]
|
val item = data.episodes[index]
|
||||||
queue(if (item.isPosterBackdrop) item.poster else item.backdrop, WolfskinTokens.Image.tileRequestWidth, WolfskinTokens.Image.tileRequestHeight)
|
queue(if (item.isPosterBackdrop) item.poster else item.backdrop, WolfskinTokens.Image.episodeRequestWidth, WolfskinTokens.Image.episodeRequestHeight)
|
||||||
}
|
}
|
||||||
"related" -> for (index in 0 until minOf(data.related.size, WolfskinTokens.Card.posterSlots))
|
"related" -> for (index in 0 until minOf(data.related.size, WolfskinTokens.Card.posterSlots))
|
||||||
queue(data.related[index].poster, WolfskinTokens.Image.posterRequestWidth, WolfskinTokens.Image.posterRequestHeight, true)
|
queue(data.related[index].poster, WolfskinTokens.Image.posterRequestWidth, WolfskinTokens.Image.posterRequestHeight, true)
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue