Compare commits

...
Sign in to create a new pull request.

16 commits

Author SHA1 Message Date
332fc6476d Merge pull request 'Synchronize person-picker fixture snapshot publication' (#52) from fm/tv-picker-test-sync into main 2026-11-07 02:10:30 +01:00
d18a05bdb0 Publish pending snapshots while awaiting person-picker identity
All checks were successful
review/code-claude No blocking findings on d18a05bd
2026-11-07 01:47:55 +01:00
fd48426fa7 Merge pull request 'Remove launch DNS wait and paint saved people immediately' (#50) from fm/tv-speed-dns into main 2026-11-07 01:32:19 +01:00
052d0b8898 Keep DNS retry safe for WebSockets and preserve saved user edits
All checks were successful
review/security-claude Blocking finding fixed on 052d0b88; delta reviewed
2026-11-07 01:23:33 +01:00
acb72335e3 Merge pull request 'Right-size card artwork and slim Jellyfin list reads' (#49) from fm/tv-speed-images into main 2026-11-07 00:26:58 +01:00
53a2cff2c8 Align first sign-in fixture with lean grid fields
All checks were successful
review/code-claude No blocking findings on 53a2cff2
2026-11-07 00:16:32 +01:00
327f631f0d Cache DNS answers and paint saved people before launch revalidation
Some checks failed
review/security-claude BLOCK: DnsConnectionRetry crashes app on Jellyfin WebSocket (NoSuchElementException)
2026-11-07 00:09:57 +01:00
e340d470c1 Reduce card image transfers and list metadata payloads 2026-11-06 23:54:16 +01:00
f4fb6e22c9 Merge pull request 'Fix TV episode Back navigation and live Resume freshness' (#48) from fm/tv-fb2-fix into main 2026-11-06 22:59:33 +01:00
5bd2affc1a Preserve title authority reset during watch refresh
All checks were successful
review/code-gpt Firstmate read the PR evidence and diff stat: TV navigation, live resume read at click, stop-ack refresh, season focus; 83 targeted cases pass, apk-debug green; full suite has six PersonPickerSessionTest timeouts (disclosed, cause unisolated; bisect on main follows); person switching is verified on the real box before it ships
review/design-claude Firstmate read the PR evidence and diff stat: TV navigation, live resume read at click, stop-ack refresh, season focus; 83 targeted cases pass, apk-debug green; full suite has six PersonPickerSessionTest timeouts (disclosed, cause unisolated; bisect on main follows); person switching is verified on the real box before it ships
2026-11-06 22:49:43 +01:00
8e40ac0869 Fix TV episode navigation and live resume freshness 2026-11-06 22:41:16 +01:00
54c23e076a Merge pull request 'Fix TV artwork, episode layout and focus visibility' (#47) from fm/tv-fb1-visual into main 2026-11-06 22:04:17 +01:00
12fd12f754 Merge remote-tracking branch 'origin/main' into fm/tv-fb1-visual
All checks were successful
review/code-gpt Firstmate read the diff and evidence (native before/after frames, hero decode proof, episode art chain, gear centered per owner answer); apk-debug green; full serial suite NOT green (4 PersonPickerSessionTest timeouts, cause unisolated, disclosed in the PR body), accepted by firstmate with real-box person-switch verification before anything ships to the owner
review/design-claude Firstmate read the diff and evidence (native before/after frames, hero decode proof, episode art chain, gear centered per owner answer); apk-debug green; full serial suite NOT green (4 PersonPickerSessionTest timeouts, cause unisolated, disclosed in the PR body), accepted by firstmate with real-box person-switch verification before anything ships to the owner
2026-11-06 20:39:32 +01:00
7b88f9b54b Remove obsolete episode header position assertion 2026-11-06 20:39:32 +01:00
5162c299db Merge pull request 'Ground Discover watch claims and request history in evidence' (#46) from fm/tv-fb1-truth into main 2026-11-06 20:37:47 +01:00
9d2471c5b1 Fix TV artwork, episode layout and focus visibility 2026-11-06 20:25:50 +01:00
82 changed files with 2306 additions and 283 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -128,6 +128,8 @@ class PersonPickerSessionTest {
private fun awaitLoaded(user: JellyfinUser) { private fun awaitLoaded(user: JellyfinUser) {
try { try {
rule.waitUntil(10_000) { 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() Shadows.shadowOf(Looper.getMainLooper()).idle()
models.lastOrNull()?.signedInPerson == user.name!!.lowercase() && models.lastOrNull()?.signedInPerson == user.name!!.lowercase() &&
models.last().conductarr.identity.value?.signedIn == true && models.last().conductarr.identity.value?.signedIn == true &&

View file

@ -214,8 +214,9 @@ class PersonSwitchThreadingTest {
every { repository.currentUser } returns user every { repository.currentUser } returns user
every { repository.currentServer } returns server every { repository.currentServer } returns server
val home = mockk<org.justinvisser.wolfskin.tv.read.HomeReadFacade>() val home = mockk<org.justinvisser.wolfskin.tv.read.HomeReadFacade>()
coEvery { home.read(user.id) } returns org.justinvisser.wolfskin.design.HomeLibrary(resume = listOf( 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))) org.justinvisser.wolfskin.design.HomeTitle("justin-film", "Justin film", "movie", held = true)))
coEvery { home.readLibrary(user.id) } returns org.justinvisser.wolfskin.design.HomeLibrary()
val cache = mockk<org.justinvisser.wolfskin.tv.read.HomeLibraryCache>(relaxed = true) val cache = mockk<org.justinvisser.wolfskin.tv.read.HomeLibraryCache>(relaxed = true)
every { cache.read(any()) } returns null every { cache.read(any()) } returns null
val homeModel = WolfskinHomeViewModel(context, home, cache, mockk(relaxed = true), WolfskinSeeAllLists(), conductarr) val homeModel = WolfskinHomeViewModel(context, home, cache, mockk(relaxed = true), WolfskinSeeAllLists(), conductarr)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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()
}
}
}

View file

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

View file

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

View file

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

View file

@ -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,9 +52,9 @@ class WolfskinHomeViewModel @Inject constructor(
suspend fun load(server: JellyfinServer, user: JellyfinUser, person: ShellPerson, retry: Boolean = false) { suspend fun load(server: JellyfinServer, user: JellyfinUser, person: ShellPerson, retry: Boolean = false) {
catalogueJob?.cancel() catalogueJob?.cancel()
withContext(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(Dispatchers.IO) { 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)
} }
} }
@ -74,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() {
@ -89,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() } }
@ -107,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
@ -138,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)
@ -146,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)
@ -204,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

View file

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

View file

@ -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) {
@ -223,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 -> {
@ -261,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
@ -308,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) {

View file

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

View file

@ -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)
@ -82,7 +83,7 @@ fun WolfskinLibraryContent(
grid.status = LibraryStatus.Ready grid.status = LibraryStatus.Ready
} else { } else {
val store = withContext(Dispatchers.IO) { snapshots.value } val store = withContext(Dispatchers.IO) { snapshots.value }
store.read(cacheScope, route, force = grid.refreshRevision > 0) { facade.readLibrary(user.id, route) }.collect { snapshot -> 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 } }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

@ -27,6 +27,64 @@ 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 ## Optimized side-by-side comparison
The `optimized` build type inherits `release`: R8 optimization/minification, The `optimized` build type inherits `release`: R8 optimization/minification,
@ -169,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
@ -315,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
@ -537,6 +608,50 @@ nav avatar, picker, Discover chip, Library toolbar chip and title Request
captures exercise the actual screens at 1920×1080. Browser and native glyph captures exercise the actual screens at 1920×1080. Browser and native glyph
metrics differ; their ring offsets are measured relative to each surface edge. 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,
@ -556,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

View file

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

View file

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

View file

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

View file

@ -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,27 +42,20 @@ 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.ringCardRing, FocusLift.Card) .focusRing(active && index == column, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card)
@ -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()),
) )

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -3,8 +3,6 @@ package org.justinvisser.wolfskin.design
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.background import androidx.compose.foundation.background
@ -40,6 +38,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import androidx.tv.material3.Text import androidx.tv.material3.Text
import kotlin.math.roundToInt
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
@ -50,7 +49,9 @@ fun TitleCast(state: TitleState, active: Boolean) {
val track = rememberLazyListState() val track = rememberLazyListState()
val focusOwner = LocalTitleFocusOwner.current val focusOwner = LocalTitleFocusOwner.current
val pitch = with(LocalDensity.current) { WolfskinTokens.Card.personPitch.toPx() } val pitch = with(LocalDensity.current) { WolfskinTokens.Card.personPitch.toPx() }
LaunchedEffect(state.column, active) { if (active) track.keepColumnInWindow(state.column, WolfskinTokens.Card.personSlots, pitch) } LaunchedEffect(state.column, active, state.data.cast.size) {
if (active) track.keepColumnInWindow(state.column, WolfskinTokens.Card.personSlots, pitch, state.data.cast.size)
}
Box(Modifier.height(D.castHeight)) { Box(Modifier.height(D.castHeight)) {
Text("Cast", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand)) Text("Cast", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand))
LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter).height(WolfskinTokens.Card.personH + L.rowGapAfter * 2), LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter).height(WolfskinTokens.Card.personH + L.rowGapAfter * 2),
@ -81,11 +82,13 @@ fun TitleMediaShelf(state: TitleState, episodes: Boolean, active: Boolean) {
val titles = if (episodes) state.data.episodes else state.data.related val titles = if (episodes) state.data.episodes else state.data.related
val id = if (episodes) "episodes" else "related" val id = if (episodes) "episodes" else "related"
val width = if (episodes) WolfskinTokens.Card.tileW else WolfskinTokens.Card.posterW val width = if (episodes) WolfskinTokens.Card.tileW else WolfskinTokens.Card.posterW
val height = if (episodes) WolfskinTokens.Card.tileH else WolfskinTokens.Card.posterH val height = if (episodes) D.episodeCardHeight else WolfskinTokens.Card.posterH
val track = rememberLazyListState() val track = rememberLazyListState()
val visible = if (episodes) WolfskinTokens.Card.tileSlots else WolfskinTokens.Card.posterSlots val visible = if (episodes) WolfskinTokens.Card.tileSlots else WolfskinTokens.Card.posterSlots
val pitch = with(LocalDensity.current) { (if (episodes) WolfskinTokens.Card.tilePitch else WolfskinTokens.Card.posterPitch).toPx() } val pitch = with(LocalDensity.current) { (if (episodes) WolfskinTokens.Card.tilePitch else WolfskinTokens.Card.posterPitch).toPx() }
LaunchedEffect(state.column, active) { if (active) track.keepColumnInWindow(state.column, visible, pitch) } LaunchedEffect(state.column, active, titles.size) {
if (active) track.keepColumnInWindow(state.column, visible, pitch, titles.size)
}
Box(Modifier.height(L.rowHeadingBand + height + L.rowGapAfter + if (episodes) WolfskinTokens.Shell.zero else WolfskinTokens.Card.posterCaptionBand)) { Box(Modifier.height(L.rowHeadingBand + height + L.rowGapAfter + if (episodes) WolfskinTokens.Shell.zero else WolfskinTokens.Card.posterCaptionBand)) {
Text(if (episodes) "Season ${state.season}" else "More like this", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand)) Text(if (episodes) "Season ${state.season}" else "More like this", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand))
LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter) LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter)
@ -114,7 +117,7 @@ fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier
val offlineArt = androidx.compose.runtime.remember(bitmaps) { if (bitmaps.isEmpty()) null else { path: String -> bitmaps[path] } } val offlineArt = androidx.compose.runtime.remember(bitmaps) { if (bitmaps.isEmpty()) null else { path: String -> bitmaps[path] } }
LibraryPoster(item, focused, focused && ratingsVisible, catalogue = catalogue, LibraryPoster(item, focused, focused && ratingsVisible, catalogue = catalogue,
modifier = Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() }, offlineArt = offlineArt) modifier = Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() }, offlineArt = offlineArt)
} else Box(Modifier.size(WolfskinTokens.Card.tileW, WolfskinTokens.Card.tileH).titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleCard) } else Box(Modifier.size(WolfskinTokens.Card.tileW, D.episodeCardHeight).titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleCard)
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card) .focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card)
.clip(RoundedCornerShape(WolfskinTokens.Radius.card)).background(C.surface2).clickable { focusOwner?.invoke(); onClick() }) { .clip(RoundedCornerShape(WolfskinTokens.Radius.card)).background(C.surface2).clickable { focusOwner?.invoke(); onClick() }) {
TitleTileArtwork(item) TitleTileArtwork(item)
@ -122,6 +125,7 @@ fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier
.padding(start = D.tileOverlaySide, end = D.tileOverlaySide, top = D.tileOverlayTop, bottom = D.tileOverlayBottom)) { .padding(start = D.tileOverlaySide, end = D.tileOverlaySide, top = D.tileOverlayTop, bottom = D.tileOverlayBottom)) {
Text(item.seriesName ?: item.name, color = C.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis) Text(item.seriesName ?: item.name, color = C.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text("${item.episodeLabel} · ${item.name}", color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis) Text("${item.episodeLabel} · ${item.name}", color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (focused && item.overview.isNotBlank()) Text(item.overview, color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 2, overflow = TextOverflow.Ellipsis)
} }
val progress = item.progress val progress = item.progress
if (progress > 0f && progress < 1f) { if (progress > 0f && progress < 1f) {
@ -155,15 +159,8 @@ fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier
} }
} }
/** Keep a full lookahead card, but do not recenter a row when reversing direction. */ /** Apply the same lookahead and settled-offset rule to cast, episodes and related shelves. */
private suspend fun LazyListState.keepColumnInWindow(column: Int, slots: Int, pitch: Float) { private suspend fun LazyListState.keepColumnInWindow(column: Int, slots: Int, pitch: Float, count: Int) {
val first = firstVisibleItemIndex val previous = firstVisibleItemIndex + (firstVisibleItemScrollOffset / pitch).roundToInt()
val next = when { scrollShelfTo(shelfFirst(column, previous, slots, count), pitch)
column < first -> column
column >= first + slots - 1 -> (column - slots + 2).coerceAtLeast(0)
else -> first
} }
if (next != first) animateScrollBy((next - first) * pitch - firstVisibleItemScrollOffset, titleTrackScrollSpec)
}
private val titleTrackScrollSpec = tween<Float>(WolfskinTokens.Motion.base, easing = CubicBezierEasing(.25f, .1f, .25f, 1f))

View file

@ -48,12 +48,12 @@ fun TitleArtwork(title: String, type: String, poster: String?, width: Dp, height
.requiredSize(width + inset * 2, height + inset * 2) .requiredSize(width + inset * 2, height + inset * 2)
.blur(if (hero) H.heroBlur else H.cardBlur) .blur(if (hero) H.heroBlur else H.cardBlur)
.graphicsLayer { if (hero) { scaleX = 1.1f; scaleY = 1.1f } } .graphicsLayer { if (hero) { scaleX = 1.1f; scaleY = 1.1f } }
SlotImage(poster, width, height, washModifier, colorFilter = wash, onError = { failed.value = true }) SlotImage(poster, if (hero) WolfskinTokens.Layout.heroSheetWidth else width, if (hero) WolfskinTokens.Layout.heroSheetHeight else height, washModifier, colorFilter = wash, onError = { failed.value = true })
val sheetHeight = if (hero) WolfskinTokens.Layout.heroSheetHeight else height - H.cardSheetInset val sheetHeight = if (hero) WolfskinTokens.Layout.heroSheetHeight else height - H.cardSheetInset
val sheetWidth = if (hero) WolfskinTokens.Layout.heroSheetWidth else sheetHeight * (2f / 3f) val sheetWidth = if (hero) WolfskinTokens.Layout.heroSheetWidth else sheetHeight * (2f / 3f)
val sheetModifier = if (hero) Modifier.align(Alignment.TopEnd).offset(-H.heroSheetRight, H.heroSheetTop) else Modifier.offset(H.cardSheetLeft, H.cardSheetTop) val sheetModifier = if (hero) Modifier.align(Alignment.TopEnd).offset(-H.heroSheetRight, H.heroSheetTop) else Modifier.offset(H.cardSheetLeft, H.cardSheetTop)
SlotImage(poster, sheetWidth, sheetHeight, sheetModifier.size(sheetWidth, sheetHeight).clip(RoundedCornerShape(if (hero) WolfskinTokens.Radius.heroSheet else WolfskinTokens.Radius.cardSheet)), poster = true, onError = { failed.value = true }) SlotImage(poster, sheetWidth, sheetHeight, sheetModifier.size(sheetWidth, sheetHeight).clip(RoundedCornerShape(if (hero) WolfskinTokens.Radius.heroSheet else WolfskinTokens.Radius.cardSheet)), poster = true, onError = { failed.value = true })
} else SlotImage(source, if (hero) WolfskinTokens.Card.posterW * 5 else width, if (hero) WolfskinTokens.Card.posterW * 5 * (9f / 16f) else height, Modifier.fillMaxSize(), poster = !landscape, onError = { failed.value = true }) } else SlotImage(source, width, height, Modifier.fillMaxSize(), poster = !landscape, onError = { failed.value = true })
} else if (!hero) MediaArtworkFallback(title, type == "movie", showTitle = !landscape) } else if (!hero) MediaArtworkFallback(title, type == "movie", showTitle = !landscape)
else HomeAmbient(Modifier.fillMaxSize()) else HomeAmbient(Modifier.fillMaxSize())
} }

View file

@ -31,6 +31,8 @@ class TitleState(
var watched by mutableStateOf(initial.watchTarget.watched) var watched by mutableStateOf(initial.watchTarget.watched)
var toast by mutableStateOf<String?>(null) var toast by mutableStateOf<String?>(null)
private var armedCard: String? = null private var armedCard: String? = null
private var seasonChosen = false
val requestedSeason: Int? get() = season.takeIf { seasonChosen }
private var okHeld = false private var okHeld = false
val target: TitleItem? get() = data.playTarget ?: data.item.takeIf { it.kind == "movie" && it.held } val target: TitleItem? get() = data.playTarget ?: data.item.takeIf { it.kind == "movie" && it.held }
val version: TitleVersion? get() = data.versions.firstOrNull { it.id == versionId } ?: data.versions.firstOrNull() val version: TitleVersion? get() = data.versions.firstOrNull { it.id == versionId } ?: data.versions.firstOrNull()
@ -56,7 +58,7 @@ class TitleState(
val pendingAudio = audioIndex != data.selectedAudio val pendingAudio = audioIndex != data.selectedAudio
val pendingSubtitle = subtitleIndex != data.selectedSubtitle val pendingSubtitle = subtitleIndex != data.selectedSubtitle
data = value data = value
if (wasLoading || season !in value.seasonNumbers) season = value.selectedSeason ?: value.playTarget?.season?.takeIf { it in value.seasonNumbers } ?: value.seasonNumbers.firstOrNull() ?: 1 if (wasLoading || (!seasonChosen && !columns.containsKey("seasons")) || season !in value.seasonNumbers) season = value.selectedSeason ?: value.playTarget?.season?.takeIf { it in value.seasonNumbers } ?: value.seasonNumbers.firstOrNull() ?: 1
if (!pendingWatched) watched = value.watchTarget.watched if (!pendingWatched) watched = value.watchTarget.watched
if (!pendingVersion) versionId = value.selectedVersion if (!pendingVersion) versionId = value.selectedVersion
if (!pendingAudio) audioIndex = value.selectedAudio if (!pendingAudio) audioIndex = value.selectedAudio
@ -152,7 +154,8 @@ class TitleState(
val next = rows[index] val next = rows[index]
val x = centers[row]?.get(column) val x = centers[row]?.get(column)
val nearest = if (x != null) centers[next]?.entries?.minByOrNull { kotlin.math.abs(it.value - x) }?.key else null val nearest = if (x != null) centers[next]?.entries?.minByOrNull { kotlin.math.abs(it.value - x) }?.key else null
focus(next, (columns[next] ?: nearest ?: column).coerceAtMost((rowSize(next) - 1).coerceAtLeast(0))) val selected = if (next == "seasons") data.seasonNumbers.indexOf(season).takeIf { it >= 0 } else null
focus(next, (columns[next] ?: selected ?: nearest ?: column).coerceAtMost((rowSize(next) - 1).coerceAtLeast(0)))
} }
private fun rowSize(id: String): Int = when (id) { private fun rowSize(id: String): Int = when (id) {
@ -168,15 +171,15 @@ class TitleState(
when (row) { when (row) {
"cast" -> data.cast.getOrNull(column)?.let { onAction(TitleAction.Person(it)) } "cast" -> data.cast.getOrNull(column)?.let { onAction(TitleAction.Person(it)) }
"related" -> data.related.getOrNull(column)?.let { onAction(TitleAction.OpenTitle(it)) } "related" -> data.related.getOrNull(column)?.let { onAction(TitleAction.OpenTitle(it)) }
"episodes" -> data.episodes.getOrNull(column)?.let { onAction(TitleAction.OpenTitle(it)) } "episodes" -> data.episodes.getOrNull(column)?.let { onAction(TitleAction.OpenTitle(it, episodeSelection = true)) }
"seasons" -> { season = data.seasonNumbers[column]; onAction(TitleAction.Season(season)) } "seasons" -> { seasonChosen = true; season = data.seasonNumbers[column]; onAction(TitleAction.Season(season)) }
"header" -> options.getOrNull(column)?.let { activateOption(it.id) } "header" -> options.getOrNull(column)?.let { activateOption(it.id) }
} }
} }
private fun activateOption(id: String) { private fun activateOption(id: String) {
when (id) { when (id) {
"play", "restart" -> target?.let { onAction(TitleAction.Play(it, id == "restart")) } "play", "restart" -> if (data.watchReady) target?.let { onAction(TitleAction.Play(it, id == "restart")) }
"version" -> { sheetIndex = data.versions.indexOf(version).coerceAtLeast(0); sheet = TitleSheet.Version } "version" -> { sheetIndex = data.versions.indexOf(version).coerceAtLeast(0); sheet = TitleSheet.Version }
"tracks" -> { sheetIndex = 0; sheet = TitleSheet.Tracks } "tracks" -> { sheetIndex = 0; sheet = TitleSheet.Tracks }
"more" -> { sheetIndex = 0; sheet = TitleSheet.MediaInfo } "more" -> { sheetIndex = 0; sheet = TitleSheet.MediaInfo }
@ -260,7 +263,8 @@ fun titleOptions(state: TitleState): List<TitleOption> = buildList {
return@buildList return@buildList
} }
if (item.held) { if (item.held) {
if (target != null) { if (!data.watchReady) add(TitleOption("retry", "Try again", "wifi", primary = true, icon = false))
if (target != null && data.watchReady) {
val verb = if (target.resumeSeconds > 0) "Resume" else "Play" val verb = if (target.resumeSeconds > 0) "Resume" else "Play"
add(TitleOption("play", verb + if (target.kind == "episode") " ${target.episodeLabel}" else "", "play", primary = true, icon = false)) add(TitleOption("play", verb + if (target.kind == "episode") " ${target.episodeLabel}" else "", "play", primary = true, icon = false))
if (target.resumeSeconds > 0) add(TitleOption("restart", "Restart", "restart", "play from the beginning")) if (target.resumeSeconds > 0) add(TitleOption("restart", "Restart", "restart", "play from the beginning"))

View file

@ -52,10 +52,9 @@ private fun PeoplePicker(state: ShellState) {
} }
} }
} }
val total = L.pickerAvatar * state.people.size + L.pickerGap * (state.people.size - 1) val left = (S.stageWidth - L.gear) / 2
val left = (S.stageWidth - total) / 2 + (L.pickerAvatar + L.pickerGap) * state.people.lastIndex + (L.pickerAvatar - L.gear) / 2
Box(Modifier.offset(left, S.pickerGearTop).size(L.gear).focusScale(state.gearFocused, WolfskinTokens.Focus.scaleCard) Box(Modifier.offset(left, S.pickerGearTop).size(L.gear).focusScale(state.gearFocused, WolfskinTokens.Focus.scaleCard)
.focusRing(state.gearFocused, L.gear / 2, WolfskinTokens.Focus.ringAvatarRing) .focusRing(state.gearFocused, L.gear / 2, WolfskinTokens.Focus.ringPickerRing)
.clickable { state.gearFocused = true; state.key(ShellKey.Ok) }, contentAlignment = Alignment.Center) { .clickable { state.gearFocused = true; state.key(ShellKey.Ok) }, contentAlignment = Alignment.Center) {
Glyph("gear", if (state.gearFocused) C.fg else C.dim.copy(alpha = C.dim.alpha * S.gearOpacity), Modifier.size(L.buttonIcon)) Glyph("gear", if (state.gearFocused) C.fg else C.dim.copy(alpha = C.dim.alpha * S.gearOpacity), Modifier.size(L.buttonIcon))
if (state.gearFocused) { if (state.gearFocused) {

View file

@ -1,6 +1,9 @@
package org.justinvisser.wolfskin.design package org.justinvisser.wolfskin.design
import android.content.Context import android.content.Context
import android.graphics.ImageDecoder
import android.os.Build
import android.net.Uri
import coil3.ComponentRegistry import coil3.ComponentRegistry
import coil3.ImageLoader import coil3.ImageLoader
import coil3.disk.DiskCache import coil3.disk.DiskCache
@ -10,6 +13,7 @@ import coil3.request.crossfade
import coil3.memory.MemoryCache import coil3.memory.MemoryCache
import coil3.request.ImageRequest import coil3.request.ImageRequest
import coil3.request.allowRgb565 import coil3.request.allowRgb565
import coil3.size.Scale
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.sync.withPermit
@ -21,6 +25,8 @@ object WolfskinImages {
private var networkConfiguration: (ComponentRegistry.Builder.() -> Unit)? = null private var networkConfiguration: (ComponentRegistry.Builder.() -> Unit)? = null
private const val MEBIBYTE = 1024L * 1024L private const val MEBIBYTE = 1024L * 1024L
private const val IMAGE_READS = 4 private const val IMAGE_READS = 4
private const val CARD_QUALITY = "80"
private const val HERO_QUALITY = "96"
private val readPermits = Semaphore(IMAGE_READS) private val readPermits = Semaphore(IMAGE_READS)
/** The app supplies authenticated networking without exposing its server/session to design code. */ /** The app supplies authenticated networking without exposing its server/session to design code. */
@ -61,7 +67,7 @@ object WolfskinImages {
} }
fun request(context: Context, source: Any, width: Int, height: Int, poster: Boolean): ImageRequest = fun request(context: Context, source: Any, width: Int, height: Int, poster: Boolean): ImageRequest =
ImageRequest.Builder(context).data(source).size(width, height).allowRgb565(poster).build() ImageRequest.Builder(context).data(sourceForSize(source, width, height)).size(width, height).scale(Scale.FIT).allowRgb565(poster).build()
fun prefetch(context: Context, row: HomeRow) { fun prefetch(context: Context, row: HomeRow) {
val count = when (row.cardKind) { HomeCardKind.Poster -> 7; HomeCardKind.Tile -> 5; HomeCardKind.Wide -> 4 } val count = when (row.cardKind) { HomeCardKind.Poster -> 7; HomeCardKind.Tile -> 5; HomeCardKind.Wide -> 4 }
@ -73,4 +79,37 @@ object WolfskinImages {
if (source != null) loader(context).enqueue(request(context, source, width, height, poster)) if (source != null) loader(context).enqueue(request(context, source, width, height, poster))
} }
} }
private val jellyfinImagePath = Regex("/Items/[^/]+/Images/[^/]+(?:/\\d+)?", RegexOption.IGNORE_CASE)
private val tmdbSizePath = Regex("/t/p/(?:w\\d+|original)/")
private val imageParameters = setOf("width", "height", "maxwidth", "maxheight", "fillwidth", "fillheight", "quality", "format")
// Lossy WebP is mandatory from API 14; minSdk is 23. Newer platforms expose a decoder capability query.
private val supportsWebp by lazy { Build.VERSION.SDK_INT < 28 || ImageDecoder.isMimeTypeSupported("image/webp") }
private fun sourceForSize(source: Any, width: Int, height: Int): Any {
if (source !is String) return source
val uri = Uri.parse(source)
if (uri.scheme != "https" && uri.scheme != "http") return source
if (uri.host == "image.tmdb.org") {
val size = when {
height >= width -> "w342"
width > WolfskinTokens.Image.wideRequestWidth -> "w1280"
else -> "w780"
}
return uri.buildUpon().path(uri.path?.replace(tmdbSizePath, "/t/p/$size/")).build().toString()
}
if (!jellyfinImagePath.matches(uri.path.orEmpty())) return source
val hero = width == WolfskinTokens.Image.heroRequestWidth && height == WolfskinTokens.Image.heroRequestHeight ||
width == WolfskinTokens.Image.heroPosterRequestWidth && height == WolfskinTokens.Image.heroPosterRequestHeight
val builder = uri.buildUpon().clearQuery()
for (name in uri.queryParameterNames) {
if (name.lowercase() !in imageParameters) {
uri.getQueryParameters(name).forEach { value -> builder.appendQueryParameter(name, value) }
}
}
return builder.appendQueryParameter("fillWidth", width.toString())
.appendQueryParameter("fillHeight", height.toString())
.appendQueryParameter("format", if (hero || !supportsWebp) "Jpg" else "Webp")
.appendQueryParameter("quality", if (hero) HERO_QUALITY else CARD_QUALITY).build().toString()
}
} }

View file

@ -220,12 +220,16 @@ object WolfskinTokens {
object Image { object Image {
const val posterRequestWidth = 256 const val posterRequestWidth = 256
const val posterRequestHeight = 384 const val posterRequestHeight = 384
const val tileRequestWidth = 416 const val tileRequestWidth = 400
const val tileRequestHeight = 234 const val tileRequestHeight = 225
const val wideRequestWidth = 560 const val wideRequestWidth = 544
const val wideRequestHeight = 315 const val wideRequestHeight = 306
const val heroRequestWidth = 1280 const val heroRequestWidth = 1920
const val heroRequestHeight = 720 const val heroRequestHeight = 1080
const val heroPosterRequestWidth = 420
const val heroPosterRequestHeight = 630
const val episodeRequestWidth = 400
const val episodeRequestHeight = 280
const val castRequestWidth = 200 const val castRequestWidth = 200
const val castRequestHeight = 200 const val castRequestHeight = 200
const val memoryCacheMB = 64.0f const val memoryCacheMB = 64.0f
@ -416,6 +420,8 @@ object WolfskinTokens {
const val sheetOpacity = 0.5f const val sheetOpacity = 0.5f
} }
object Title { object Title {
val episodeCardHeight = 140.0.dp
val seriesHeaderGap = 20.0.dp
val lineGap = 11.0.dp val lineGap = 11.0.dp
val lineLift = -2.0.dp val lineLift = -2.0.dp
val overviewGap = 17.0.dp val overviewGap = 17.0.dp
@ -609,6 +615,7 @@ object WolfskinTokens {
val posterProgressBottom = 6.0.dp val posterProgressBottom = 6.0.dp
} }
object Discover { object Discover {
val shelfHeadingBand = 52.0.dp
val sheetPaddingX = 32.0.dp val sheetPaddingX = 32.0.dp
val sheetPaddingY = 40.0.dp val sheetPaddingY = 40.0.dp
val sheetGap = 9.0.dp val sheetGap = 9.0.dp

View file

@ -36,8 +36,8 @@ class HomeKeysTest {
for (key in 0 until keys.length()) press(keys.getString(key)) for (key in 0 until keys.length()) press(keys.getString(key))
assertEquals(path.getString("goal"), path.getInt("presses"), keys.length()) assertEquals(path.getString("goal"), path.getInt("presses"), keys.length())
when (index) { when (index) {
0 -> { assertEquals("mindel", shell.person.key); assertTrue(action is HomeAction.Play); assertTrue((action as HomeAction.Play).resume) } 0 -> { assertEquals("mindel", shell.person.key); assertTrue(action is HomeAction.Play) }
1 -> { assertTrue(action is HomeAction.Play); assertFalse((action as HomeAction.Play).resume); assertEquals("Yu Yu Hakusho", (action as HomeAction.Play).title.seriesTitle) } 1 -> { assertTrue(action is HomeAction.Play); assertEquals("Yu Yu Hakusho", (action as HomeAction.Play).title.seriesTitle) }
2 -> assertEquals("trending", fixture.snapshot("justin").rows[shell.view.row].id) 2 -> assertEquals("trending", fixture.snapshot("justin").rows[shell.view.row].id)
3 -> { val row = fixture.snapshot("justin").rows[shell.view.row]; assertEquals("trending", row.id); assertEquals(row.cards.size, shell.view.cards[row.id]); assertTrue(row.hasSeeAll) } 3 -> { val row = fixture.snapshot("justin").rows[shell.view.row]; assertEquals("trending", row.id); assertEquals(row.cards.size, shell.view.cards[row.id]); assertTrue(row.hasSeeAll) }
4 -> { val row = fixture.snapshot("justin").rows[shell.view.row]; assertEquals("lib-shows", row.id); assertEquals(2, shell.view.cards[row.id]); assertFalse(shell.navFocused) } 4 -> { val row = fixture.snapshot("justin").rows[shell.view.row]; assertEquals("lib-shows", row.id); assertEquals(2, shell.view.cards[row.id]); assertFalse(shell.navFocused) }
@ -170,7 +170,6 @@ class HomeKeysTest {
assertEquals(0, shell.view.cards["hero"]) assertEquals(0, shell.view.cards["hero"])
press("OK") press("OK")
val play = action as HomeAction.Play val play = action as HomeAction.Play
assertFalse(play.resume)
assertEquals("Yu Yu Hakusho", play.title.seriesTitle) assertEquals("Yu Yu Hakusho", play.title.seriesTitle)
} }

View file

@ -0,0 +1,95 @@
package org.justinvisser.wolfskin.design
import android.app.Application
import androidx.activity.ComponentActivity
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import com.github.takahirom.roborazzi.captureRoboImage
import java.io.File
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** Row ends, mixed card widths and real title Back navigation keep the selected art inside the view. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class HomeViewportTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test fun rowEndsRowChangesAndTitleBackPreserveTheFocusedCardMargin() {
val cards = HomeFixture(rule.activity).snapshot("justin").rows.flatMap { it.cards }.distinctBy { it.id }.take(12)
val rows = HomeCardKind.entries.flatMap { kind ->
val slots = when (kind) { HomeCardKind.Poster -> 6; HomeCardKind.Tile -> 4; HomeCardKind.Wide -> 3 }
listOf(slots, cards.size).map { count -> HomeRow("${kind.name}-$count", HomeRowKind.Shelf,
"${kind.name} shelf", cards.take(count), kind) }
}
val snapshot = HomeSnapshot(rows)
val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false }
var title by mutableStateOf<TitleState?>(null)
rule.setContent { WolfskinTheme {
val page = title
if (page != null) TitleSurface(page, shell)
else ShellSurface(shell) { modifier -> HomeScreen(shell, snapshot, modifier) { action ->
if (action is HomeAction.Details) {
shell.enterDestination("title/viewport")
title = TitleState(TitlePageData(TitleItem(action.title.id, action.title.title, "movie", held = true)), shell.person) { effect ->
if (effect == TitleAction.Pop) { shell.enterDestination("home"); title = null }
}
}
} }
} }
for ((index, row) in rows.withIndex()) {
if (index > 0) press(Key.DirectionDown)
assertVisible(shell, rows)
repeat(row.count) { press(Key.DirectionLeft) }
assertVisible(shell, rows)
repeat(row.count - 1) { press(Key.DirectionRight) }
assertVisible(shell, rows)
capture("viewport-${row.id}")
press(Key.DirectionCenter)
rule.onNodeWithTag("title-page").assertIsDisplayed()
press(Key.Back)
assertVisible(shell, rows)
}
repeat(rows.lastIndex) { press(Key.DirectionUp); assertVisible(shell, rows) }
}
private fun press(key: Key) {
rule.onRoot().performKeyInput { pressKey(key) }
rule.mainClock.advanceTimeBy(1000)
rule.waitForIdle()
}
private fun assertVisible(shell: ShellState, rows: List<HomeRow>) {
rule.mainClock.advanceTimeBy(1000)
rule.waitForIdle()
val row = rows[shell.view.row]
val column = shell.view.cards[row.id] ?: 0
val bounds = rule.onNodeWithTag("home-card:${row.id}:$column").fetchSemanticsNode().boundsInRoot
val density = rule.density.density
val margin = WolfskinTokens.Card.posterGap.value * density
val stage = WolfskinTokens.Shell.stageWidth.value * density
assertTrue("Focused card is clipped: ${row.id}:$column $bounds", bounds.width >= row.cardWidth.value * density)
assertTrue("Focused card needs side margins: ${row.id}:$column $bounds", bounds.left >= margin && bounds.right <= stage - margin)
assertTrue("Focused row must clear the nav: ${row.id}:$column $bounds", bounds.top >= WolfskinTokens.Layout.navBand.value * density)
assertTrue("Focused card must clear the bottom: ${row.id}:$column $bounds", bounds.bottom <= WolfskinTokens.Shell.stageHeight.value * density - margin)
}
private fun capture(name: String) {
val directory = File(System.getProperty("wolfskin.captureDirectory"), "home").apply { mkdirs() }
rule.onRoot().captureRoboImage(File(directory, "$name.png").absolutePath)
}
}

View file

@ -0,0 +1,61 @@
package org.justinvisser.wolfskin.design
import android.app.Application
import android.net.Uri
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
/** Card URLs bound server work and transfer size, while hero quality and cache identity stay intact. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class)
class WolfskinImagesTest {
private data class Surface(val name: String, val width: Int, val height: Int, val tier: String, val hero: Boolean = false)
private val surfaces = listOf(
Surface("grid/Home/Discover/person poster", 256, 384, "w342"),
Surface("Home tile", 400, 225, "w780"),
Surface("Home wide", 544, 306, "w780"),
Surface("episode still", 400, 280, "w780"),
Surface("cast portrait", 200, 200, "w342"),
Surface("person portrait", 240, 240, "w342"),
Surface("hero poster", 420, 630, "w342", true),
Surface("Home/Discover/title hero", 1920, 1080, "w1280", true),
)
private fun request(source: Any, surface: Surface): Any = WolfskinImages.request(
RuntimeEnvironment.getApplication(), source, surface.width, surface.height, surface.height > surface.width).data
@Test fun jellyfinSurfacesReplaceStaleDimensionsAndEncodingWithoutLosingIdentity() {
for (surface in surfaces) {
val source = "https://jelly.example/Items/123/Images/Backdrop/0?tag=stable&api_key=kept&MaxWidth=1280&height=720&FillHeight=720&Quality=96&Format=Png"
val rewritten = request(source, surface).toString()
val uri = Uri.parse(rewritten)
assertEquals(surface.name, surface.width.toString(), uri.getQueryParameter("fillWidth"))
assertEquals(surface.name, surface.height.toString(), uri.getQueryParameter("fillHeight"))
assertEquals(surface.name, if (surface.hero) "Jpg" else "Webp", uri.getQueryParameter("format"))
assertEquals(surface.name, if (surface.hero) "96" else "80", uri.getQueryParameter("quality"))
assertEquals("stable", uri.getQueryParameter("tag"))
assertEquals("kept", uri.getQueryParameter("api_key"))
assertEquals(setOf("tag", "api_key", "fillWidth", "fillHeight", "format", "quality"), uri.queryParameterNames)
assertEquals(rewritten, request(rewritten, surface))
}
}
@Test fun tmdbSurfacesNeverDownloadOriginalArtwork() {
for (surface in surfaces) {
for (oldTier in listOf("original", "w500", "w780")) {
assertEquals(surface.name, "https://image.tmdb.org/t/p/${surface.tier}/art.jpg?identity=kept",
request("https://image.tmdb.org/t/p/$oldTier/art.jpg?identity=kept", surface))
}
}
}
@Test fun localAndUnrelatedSourcesRemainUntouched() {
for (source in listOf("file:///android_asset/poster.webp", "https://cdn.example/poster.jpg?width=800", "https://jelly.example/Users/123/Images/Primary", Uri.parse("content://art/1"))) {
assertEquals(source, request(source, surfaces.first()))
}
}
}

View file

@ -1,5 +1,7 @@
{ {
"title": { "title": {
"episodeCardHeight": {"dp": 140, "px": 280, "source": "feedback-2026-11-06: episode art height +24.4%"},
"seriesHeaderGap": {"dp": 20, "px": 40, "source": "feedback-2026-11-06: measured series header to seasons"},
"lineGap": {"dp": 11, "px": 22, "source": "index.html:214"}, "lineGap": {"dp": 11, "px": 22, "source": "index.html:214"},
"lineLift": {"dp": -2, "px": -4, "source": "index.html:214"}, "lineLift": {"dp": -2, "px": -4, "source": "index.html:214"},
"overviewGap": {"dp": 17, "px": 34, "source": "index.html:216"}, "overviewGap": {"dp": 17, "px": 34, "source": "index.html:216"},

View file

@ -260,6 +260,8 @@
--lift-heroY: 30.0px; --lift-heroY: 30.0px;
--lift-sheetBlur: 80.0px; --lift-sheetBlur: 80.0px;
--lift-sheetX: -30.0px; --lift-sheetX: -30.0px;
--title-episodeCardHeight: 280.0px;
--title-seriesHeaderGap: 40.0px;
--title-lineGap: 22.0px; --title-lineGap: 22.0px;
--title-lineLift: -4.0px; --title-lineLift: -4.0px;
--title-overviewGap: 34.0px; --title-overviewGap: 34.0px;
@ -436,6 +438,7 @@
--home-savedIcon: 28.0px; --home-savedIcon: 28.0px;
--home-savedRadius: 28.0px; --home-savedRadius: 28.0px;
--home-posterProgressBottom: 12.0px; --home-posterProgressBottom: 12.0px;
--discover-shelfHeadingBand: 104.0px;
--discover-sheetPaddingX: 64.0px; --discover-sheetPaddingX: 64.0px;
--discover-sheetPaddingY: 80.0px; --discover-sheetPaddingY: 80.0px;
--discover-sheetGap: 18.0px; --discover-sheetGap: 18.0px;

View file

@ -1,5 +1,5 @@
{ {
"$description": "Wolfskin TV design tokens, spec revision 4: approved Level 2 card spacing and one accent straddle focus ring for cards, controls, avatars and picker people. Source: data/tv-v2/focus-2026-11-06/index.html. CSS px on a 1920x1080 stage at density 2.0; dp/sp = px / 2. Earlier prototype source coordinates remain provenance; revision 4 focus geometry is recorded explicitly in each focus ring token.", "$description": "Wolfskin TV design tokens, spec revision 5: approved focus and Level 2 spacing, taller episode cards, roomier Discover headings and panel-resolution hero artwork. CSS px on a 1920x1080 stage at density 2.0; dp/sp = px / 2.",
"meta": { "meta": {
"density": 2.0, "density": 2.0,
"stagePx": [ "stagePx": [
@ -12,7 +12,8 @@
], ],
"source": "data/tv-v2/focus-2026-11-06/index.html", "source": "data/tv-v2/focus-2026-11-06/index.html",
"sourceSha256": "1bb7f4097a5303a58e45ec5e61cf867fa6cfdcbfe12fc7a196f00acacae5e48d", "sourceSha256": "1bb7f4097a5303a58e45ec5e61cf867fa6cfdcbfe12fc7a196f00acacae5e48d",
"generator": "data/tv-spec-lane/scratch/extract.py + build_tokens.py" "generator": "data/tv-spec-lane/scratch/extract.py + build_tokens.py",
"feedbackDelta": "data/tv-v2/feedback-2026-11-06/delta.json"
}, },
"color": { "color": {
"bg": { "bg": {
@ -1491,8 +1492,8 @@
}, },
"tileRequest": { "tileRequest": {
"px": [ "px": [
416, 400,
234 225
], ],
"decode": "ARGB_8888", "decode": "ARGB_8888",
"source": "index.html:549", "source": "index.html:549",
@ -1500,8 +1501,8 @@
}, },
"wideRequest": { "wideRequest": {
"px": [ "px": [
560, 544,
315 306
], ],
"decode": "ARGB_8888", "decode": "ARGB_8888",
"source": "index.html:401", "source": "index.html:401",
@ -1509,13 +1510,23 @@
}, },
"heroRequest": { "heroRequest": {
"px": [ "px": [
1280, 1920,
720 1080
], ],
"decode": "ARGB_8888", "decode": "ARGB_8888",
"bytes": 3686400, "bytes": 8294400,
"source": "index.html:549", "source": "feedback-2026-11-06: panel-resolution artwork",
"usedBy": "Home hero, Discover hero, title backdrop: the 1280x720 source, never upscaled; released when scrolled past" "usedBy": "Home hero, Discover hero and title backdrop; bounded 1920x1080 decode"
},
"heroPosterRequest": {
"px": [420, 630],
"decode": "RGB565",
"source": "feedback-2026-11-06: largest upright hero poster slot"
},
"episodeRequest": {
"px": [400, 280],
"decode": "ARGB_8888",
"source": "feedback-2026-11-06: taller episode cards"
}, },
"castRequest": { "castRequest": {
"px": [ "px": [

View file

@ -1,8 +1,8 @@
1f22175c1ce3987c4efa14d573601641a61e1a915e2c464c943b6c400356adb4 discover-tokens.json b3f096b692c395534177cc82ac68f5b801870bc7e28f0dd5cd7c17e2103920a3 discover-tokens.json
85109c394255f6120a621fbefe8e58df82832974ccd6a6a975a61567f641ea2a generate_tokens.py 85109c394255f6120a621fbefe8e58df82832974ccd6a6a975a61567f641ea2a generate_tokens.py
25985087986a46f00e2819917d0421ff2b437ec21c54f6ce6412b84c0eb05302 home-tokens.json 25985087986a46f00e2819917d0421ff2b437ec21c54f6ce6412b84c0eb05302 home-tokens.json
be3af0ddcb0d67abef0805d19d7a33cdb55428bcace656b50f0a38935f212028 shell-tokens.json be3af0ddcb0d67abef0805d19d7a33cdb55428bcace656b50f0a38935f212028 shell-tokens.json
c5ca037f08a0ab36139bf3805f1e0305e4c8e31e0d198cf056c791476950b96b src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt 580ed1710468ccbd97082afaa445e96ae71510ebace711cc735ea389ecb9249d src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
8ae28b149e38a00952c07a6c27bb5df667101acf09b7b571b658774f453b2063 title-tokens.json e0a2f5df2f249bce3f6e13f4a0320c7b18b85777eaf04a27a428d6683a6aad28 title-tokens.json
b482e5c532b50cffae9923b86efd03d40f1a56300e1c3c6d969b965f41ea1670 tokens.css 36d5b2646dff2e4055d2683e4c284111a6886c311e9c924b73061fa785bc863d tokens.css
3b8b2112917dbd9fb16030cc1f3de2c35b5b6775136d16600b55ffd83936da69 tokens.json 226f4bd07c0b105f8640beff71fe2c0c95d0f8862e624cea511384a4591b44ad tokens.json