diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt index ec61583d..6b3403c6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -32,6 +32,7 @@ import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.RestoredSession import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.services.AppUpgradeHandler @@ -576,12 +577,22 @@ class MainActivityViewModel val restoredSession = if (signInAutomatically) { Timber.v("Sign in automatically") - serverRepository.restoreLastSession() + serverRepository.restoreSavedSession() } else { serverRepository.getMostRecentServer() } Timber.d("Restore last session result=%s", restoredSession) setupNavigationManager.navigateTo(restoredSession.destination) + if (signInAutomatically && restoredSession is RestoredSession.Success) { + viewModelScope.launchDefault { + try { + serverRepository.revalidateSession(restoredSession.currentUser) + } catch (exception: Exception) { + if (exception is kotlinx.coroutines.CancellationException) throw exception + Timber.w(exception, "Saved launch session revalidation failed") + } + } + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt b/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt index 6ec9d4b4..aba2a0a6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt @@ -17,6 +17,7 @@ import org.acra.ktx.initAcra import org.jellyfin.sdk.Jellyfin import org.jellyfin.sdk.model.ServerVersion import timber.log.Timber +import org.justinvisser.wolfskin.tv.NetworkStartup import javax.inject.Inject @OptIn(ExperimentalComposeRuntimeApi::class) @@ -54,6 +55,7 @@ class WholphinApplication : override fun onCreate() { super.onCreate() + networkStartup.start() if (BuildConfig.DEBUG) { StrictMode.setThreadPolicy( StrictMode.ThreadPolicy @@ -110,6 +112,9 @@ class WholphinApplication : @Inject lateinit var workerFactory: HiltWorkerFactory + @Inject + lateinit var networkStartup: NetworkStartup + override val workManagerConfiguration: Configuration get() = Configuration diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/JellyfinServerDao.kt b/app/src/main/java/com/github/damontecres/wholphin/data/JellyfinServerDao.kt index f72eb3b7..68af5123 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/JellyfinServerDao.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/JellyfinServerDao.kt @@ -51,6 +51,10 @@ interface JellyfinServerDao { return user.copy(rowId = result.toInt()) } + /** Network name refreshes must not overwrite settings or protection changed since launch. */ + @Query("UPDATE users SET name = :name WHERE serverId = :serverId AND id = :userId") + fun updateUserName(serverId: UUID, userId: UUID, name: String?): Int + @Query("SELECT * FROM users WHERE serverId = :serverId AND id = :userId") fun getUser( serverId: UUID, diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt index 2c24c8f3..0930129e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt @@ -8,6 +8,8 @@ import com.github.damontecres.wholphin.services.hilt.IoDispatcher import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -101,15 +103,7 @@ class ServerRepository } Timber.v("Changing user to ${user.name} on ${server.url}") apiClient.update(baseUrl = server.url, accessToken = user.accessToken) - val userDto by apiClient.userApi.getCurrentUser() - val updatedServer = - try { - val sysInfo by apiClient.systemApi.getPublicSystemInfo() - server.copy(name = sysInfo.serverName, version = sysInfo.version) - } catch (ex: Exception) { - Timber.w(ex, "Exception fetching public system info") - server - } + val (updatedServer, userDto) = readSession(server) var updatedUser = user.copy( id = userDto.id, @@ -125,6 +119,45 @@ class ServerRepository return@withContext currentUser } + /** Restore only an unprotected saved login; the launch picker must not wait for the network. */ + suspend fun restoreSavedSession(): RestoredSession = withContext(ioDispatcher) { + val recent = mostRecentServerProvider.get() + if (recent !is MostRecentServer.ServerAndUser) return@withContext getMostRecentServerInternal(recent) + val saved = serverDao.getServer(recent.serverId) ?: return@withContext RestoredSession.None + val user = saved.users.firstOrNull { it.id == recent.userId } + if (user == null || user.isProtected) return@withContext RestoredSession.ServerOnly(saved.server) + apiClient.update(baseUrl = saved.server.url, accessToken = user.accessToken) + val current = CurrentUser(saved.server, user) + _currentUserDto.value = null + _current.value = current + RestoredSession.Success(current) + } + + /** A late launch refresh must never replace a person selected while it was in flight. */ + suspend fun revalidateSession(expected: CurrentUser) = withContext(ioDispatcher) { + val (server, userDto) = readSession(expected.server) + if (_current.value !== expected || userDto.id != expected.user.id) return@withContext + serverDao.addOrUpdateServer(server) + serverDao.updateUserName(expected.server.id, expected.user.id, userDto.name) + val user = serverDao.getUser(expected.server.id, expected.user.id) ?: return@withContext + if (_current.compareAndSet(expected, CurrentUser(server, user))) updateUserDto(userDto) + } + + private suspend fun readSession(server: JellyfinServer): Pair = 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 * diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt b/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt index 3208f4ec..87095f8b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt @@ -24,6 +24,13 @@ class NavigationManager 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 */ diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt index 3a114017..0591f4a1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt @@ -26,6 +26,9 @@ import org.jellyfin.sdk.api.okhttp.OkHttpFactory import org.jellyfin.sdk.createJellyfin import org.jellyfin.sdk.model.ClientInfo import org.jellyfin.sdk.model.DeviceInfo +import org.justinvisser.wolfskin.conductarr.PersistentDns +import org.justinvisser.wolfskin.conductarr.DnsConnectionRetry +import java.io.File import javax.inject.Qualifier import javax.inject.Singleton @@ -91,12 +94,14 @@ object AppModule { @StandardOkHttpClient @Provides @Singleton - fun okHttpClient() = - OkHttpClient - .Builder() - .apply { - // TODO user agent, timeouts, logging, etc - }.build() + fun okHttpClient(dns: PersistentDns) = + DnsConnectionRetry.configure(OkHttpClient.Builder(), dns).build() + + /** Resolve noBackupFilesDir lazily: Android may create it and lookup always runs off main. */ + @Provides + @Singleton + fun dns(@ApplicationContext context: Context): PersistentDns = + PersistentDns(file = { File(context.noBackupFilesDir, "wolfskin-dns.json") }) @AuthOkHttpClient @Provides diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt index b17036b8..421cc279 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt @@ -82,9 +82,11 @@ class PlayExternalViewModel ) : ViewModel() { val launched = savedStateHandle.getMutableStateFlow("launched", false) val state = MutableStateFlow(PlayExternalState()) + private var watchScope: String? = null fun init(destination: Destination) { Timber.v("init called: %s", destination) + watchScope = "${serverRepository.currentServer?.id}:${serverRepository.currentUser?.id}" state.update { it.copy(loading = LoadingState.Loading) } viewModelScope.launchDefault { val prefs = userPreferencesService.getCurrent() @@ -358,6 +360,7 @@ class PlayExternalViewModel failed = false, ), ) + watchScope?.let { com.github.damontecres.wholphin.util.PlaybackWatchState.stopped(context, it) } } } else { Timber.w( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt index 24bd8ee9..9cbbe551 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt @@ -830,10 +830,12 @@ class PlaybackViewModel } val playbackItemState = PlaybackItemState(playback) + val watchScope = "${serverRepository.currentServer?.id}:${serverRepository.currentUser?.id}" val activityListener = TrackActivityPlaybackListener( api = api, player = player, + onStopped = { com.github.damontecres.wholphin.util.PlaybackWatchState.stopped(context, watchScope) }, getState = { playbackItemState }, ) player.addListener(activityListener) diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt b/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt index d83ae269..e0c8005f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt @@ -4,6 +4,11 @@ import android.content.Context import com.github.damontecres.wholphin.data.MostRecentServer import com.github.damontecres.wholphin.services.hilt.AppModule import com.github.damontecres.wholphin.services.hilt.DeviceModule +import com.github.damontecres.wholphin.services.hilt.StandardOkHttpClient +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent import com.google.auto.service.AutoService import kotlinx.coroutines.runBlocking import okhttp3.OkHttpClient @@ -73,10 +78,7 @@ class CrashReportSender : ReportSender { private fun MostRecentServer.createApi(context: Context): ApiClient? = (this as? MostRecentServer.ServerAndUser)?.let { - val okHttpClient = - OkHttpClient - .Builder() - .build() + val okHttpClient = EntryPointAccessors.fromApplication(context.applicationContext, CrashNetwork::class.java).client() val okHttpFactory = OkHttpFactory(okHttpClient) val api = createJellyfin { @@ -89,3 +91,11 @@ private fun MostRecentServer.createApi(context: Context): ApiClient? = }.createApi(baseUrl = serverUrl, accessToken = accessToken) api } + +/** Crash reporting shares the application's DNS hints without borrowing authenticated headers. */ +@EntryPoint +@InstallIn(SingletonComponent::class) +interface CrashNetwork { + @StandardOkHttpClient + fun client(): OkHttpClient +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/PlaybackWatchState.kt b/app/src/main/java/com/github/damontecres/wholphin/util/PlaybackWatchState.kt new file mode 100644 index 00000000..3306ca18 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/util/PlaybackWatchState.kt @@ -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>(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)) } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/TrackActivityPlaybackListener.kt b/app/src/main/java/com/github/damontecres/wholphin/util/TrackActivityPlaybackListener.kt index 2ab4c2f7..859a04aa 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/TrackActivityPlaybackListener.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/TrackActivityPlaybackListener.kt @@ -7,6 +7,8 @@ import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.playback.CurrentPlayback import kotlinx.coroutines.CoroutineScope 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.extensions.playStateApi import org.jellyfin.sdk.model.api.PlayMethod @@ -31,9 +33,12 @@ import kotlin.time.Duration.Companion.seconds class TrackActivityPlaybackListener( private val api: ApiClient, private val player: Player, + private val onStopped: () -> Unit = {}, private val getState: () -> PlaybackItemState?, ) : Player.Listener { private val coroutineScope = CoroutineScope(WholphinDispatchers.Main) + private val reports = Mutex() + @Volatile private var released = false private val task: TimerTask = object : TimerTask() { override fun run() { @@ -70,19 +75,23 @@ class TrackActivityPlaybackListener( val delay = 5.seconds.inWholeMilliseconds // Every x seconds, check if the video is playing - TIMER.schedule(task, delay, delay) - initialized = true + if (!released) { + TIMER.schedule(task, delay, delay) + initialized = true + } } } } fun release() { -// player.removeListener(this) + if (released) return + released = true task.cancel() TIMER.purge() val position = player.currentPosition.milliseconds + val stoppedState = getState.invoke() launch("reportPlaybackStopped") { - getState.invoke()?.let { state -> + stoppedState?.let { state -> Timber.v("reportPlaybackStopped for ${state.itemId} at $position") if (position < Duration.ZERO) { Timber.w("Negative position when reporting playback stopped: %s", position) @@ -96,6 +105,7 @@ class TrackActivityPlaybackListener( liveStreamId = state.liveStreamId, ), ) + onStopped() } } } @@ -117,6 +127,7 @@ class TrackActivityPlaybackListener( private fun saveActivity(position: Long) { launch("saveActivity") { + if (released) return@launch getState.invoke()?.let { state -> val calcPosition = withContext(WholphinDispatchers.Main) { @@ -152,7 +163,7 @@ class TrackActivityPlaybackListener( ) { coroutineScope.launchIO { try { - block.invoke(this) + reports.withLock { block.invoke(this) } } catch (ex: Exception) { Timber.w(ex, "Exception during %s", name) } diff --git a/app/src/test/java/com/github/damontecres/wholphin/TestMainActivityViewModel.kt b/app/src/test/java/com/github/damontecres/wholphin/TestMainActivityViewModel.kt index 1de5becd..d8fe5695 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/TestMainActivityViewModel.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/TestMainActivityViewModel.kt @@ -32,6 +32,8 @@ import io.mockk.mockkStatic import io.mockk.slot import io.mockk.unmockkStatic import io.mockk.verify +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flow @@ -116,9 +118,6 @@ class TestMainActivityViewModel { signInAutomatically = true } every { serverRepository.current } returns MutableStateFlow(currentUser) -// coEvery { serverRepository.tryRestoreSession(serverId, userId) } returns currentUser -// coEvery { serverRepository.restoreLastSession() } returns -// RestoredSession.Success(currentUser) viewModel.handleAppStart() @@ -132,7 +131,7 @@ class TestMainActivityViewModel { destination as SetupDestination.AppContent Assert.assertEquals(currentUser, destination.current) } - coVerify(exactly = 0) { serverRepository.restoreLastSession() } + coVerify(exactly = 0) { serverRepository.restoreSavedSession() } coVerify(exactly = 0) { serverRepository.getMostRecentServer() } } @@ -150,7 +149,7 @@ class TestMainActivityViewModel { viewModel.handleAppStart() coVerify(exactly = 0) { serverRepository.tryChangeUser(any(), any()) } - coVerify(exactly = 0) { serverRepository.restoreLastSession() } + coVerify(exactly = 0) { serverRepository.restoreSavedSession() } coVerify(exactly = 1) { serverRepository.getMostRecentServer() } val args = mutableListOf() verify { setupNavigationManager.navigateTo(capture(args)) } @@ -165,6 +164,20 @@ class TestMainActivityViewModel { } } + @Test + fun savedColdLaunchNavigatesBeforeSessionRevalidationCompletes() = runTest(testDispatcher) { + setupPreferences { signInAutomatically = true } + every { serverRepository.current } returns MutableStateFlow(null) + coEvery { serverRepository.restoreSavedSession() } returns RestoredSession.Success(currentUser) + val release = CompletableDeferred() + coEvery { serverRepository.revalidateSession(currentUser) } coAnswers { release.await() } + viewModel.handleAppStart() + runCurrent() + verify { setupNavigationManager.navigateTo(SetupDestination.AppContent(currentUser)) } + assertFalse(release.isCompleted) + release.complete(Unit) + } + @Test fun `Test auto sign in, hot load, with protected profile`() = runTest { @@ -178,7 +191,7 @@ class TestMainActivityViewModel { coEvery { serverRepository.tryChangeUser(serverId, userId) } returns null - coEvery { serverRepository.restoreLastSession() } returns + coEvery { serverRepository.restoreSavedSession() } returns RestoredSession.ServerOnly(server) viewModel.handleAppStart() @@ -204,7 +217,7 @@ class TestMainActivityViewModel { signInAutomatically = true } every { serverRepository.current } returns MutableStateFlow(null) - coEvery { serverRepository.restoreLastSession() } returns + coEvery { serverRepository.restoreSavedSession() } returns RestoredSession.ServerOnly(server) coEvery { serverRepository.tryChangeUser(serverId, userId) @@ -212,7 +225,7 @@ class TestMainActivityViewModel { viewModel.handleAppStart() - coVerify(exactly = 1) { serverRepository.restoreLastSession() } + coVerify(exactly = 1) { serverRepository.restoreSavedSession() } val slot = slot() verify { setupNavigationManager.navigateTo(capture(slot)) } slot.captured.let { destination -> @@ -232,11 +245,11 @@ class TestMainActivityViewModel { signInAutomatically = true } every { serverRepository.current } returns MutableStateFlow(null) - coEvery { serverRepository.restoreLastSession() } returns RestoredSession.None + coEvery { serverRepository.restoreSavedSession() } returns RestoredSession.None viewModel.handleAppStart() - coVerify(exactly = 1) { serverRepository.restoreLastSession() } + coVerify(exactly = 1) { serverRepository.restoreSavedSession() } val slot = slot() verify { setupNavigationManager.navigateTo(capture(slot)) } slot.captured.let { destination -> @@ -254,7 +267,7 @@ class TestMainActivityViewModel { signInAutomatically = true } every { serverRepository.current } returns MutableStateFlow(currentUser) - coEvery { serverRepository.restoreLastSession() } returns + coEvery { serverRepository.restoreSavedSession() } returns RestoredSession.Success(currentUser) coEvery { intentService.parseIntent(any()) } returns IntentResult.NoOp @@ -271,7 +284,7 @@ class TestMainActivityViewModel { // destination as SetupDestination.AppContent // Assert.assertEquals(currentUser, destination.current) // } - coVerify(exactly = 0) { serverRepository.restoreLastSession() } + coVerify(exactly = 0) { serverRepository.restoreSavedSession() } coVerify(exactly = 0) { serverRepository.getMostRecentServer() } } diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/ServerSavedSessionTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/ServerSavedSessionTest.kt new file mode 100644 index 00000000..2724cc61 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/services/ServerSavedSessionTest.kt @@ -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() + private val system = mockk() + private val api = mockk(relaxed = true) + private val recent = mockk() + private val database by lazy { + Room.inMemoryDatabaseBuilder(ApplicationProvider.getApplicationContext(), 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(), 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() + val systemStarted = CompletableDeferred() + 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() + val pending = CompletableDeferred() + 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() + val pending = CompletableDeferred() + 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) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt index b266bb96..3b4d3efa 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt @@ -62,6 +62,7 @@ import org.jellyfin.sdk.api.okhttp.OkHttpFactory import org.jellyfin.sdk.model.ClientInfo import org.jellyfin.sdk.model.DeviceInfo import timber.log.Timber +import org.justinvisser.wolfskin.conductarr.PersistentDns import javax.inject.Singleton @Module @@ -91,12 +92,11 @@ object TestModule { @StandardOkHttpClient @Provides @Singleton - fun okHttpClient() = - OkHttpClient - .Builder() - .apply { - // TODO user agent, timeouts, logging, etc - }.build() + fun okHttpClient(dns: PersistentDns) = AppModule.okHttpClient(dns) + + @Provides + @Singleton + fun dns(@ApplicationContext context: Context): PersistentDns = AppModule.dns(context) @AuthOkHttpClient @Provides diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlayExternalViewModelTest.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlayExternalViewModelTest.kt index 511dbaa2..c6bc32f0 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlayExternalViewModelTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlayExternalViewModelTest.kt @@ -114,6 +114,7 @@ class PlayExternalViewModelTest { every { api.subtitleApi } returns subtitleApi every { api.playStateApi } returns playStateApi every { serverRepository.currentUser } returns null + every { serverRepository.currentServer } returns null coEvery { userLibraryApi.getItem(movie.id) } returns successResponse(movie) coEvery { streamChoiceService.chooseSource(any(), any()) } returns mediaSource coEvery { streamChoiceService.chooseSubtitleStream(any(), any(), any(), any(), any(), any()) } returns diff --git a/app/src/test/java/com/github/damontecres/wholphin/util/PlaybackWatchStateTest.kt b/app/src/test/java/com/github/damontecres/wholphin/util/PlaybackWatchStateTest.kt new file mode 100644 index 00000000..73791251 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/util/PlaybackWatchStateTest.kt @@ -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() + val reports = mockk() + every { api.playStateApi } returns reports + val player = mockk { 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() + 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() + val reports = mockk() + 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 { 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() + } + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrHomeSurfaceTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrHomeSurfaceTest.kt index c4e63d27..7842289d 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrHomeSurfaceTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrHomeSurfaceTest.kt @@ -52,8 +52,9 @@ class ConductarrHomeSurfaceTest { session.select("${server.id}:${user.id}", SHARED_TOKEN, fake.url) runBlocking { session.authenticate() } val home = mockk() - 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))) + coEvery { home.readLibrary(user.id) } returns HomeLibrary() val cache = mockk(relaxed = true) every { cache.read(any()) } returns null val model = WolfskinHomeViewModel(rule.activity, home, cache, NavigationManager(), WolfskinSeeAllLists(), session) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt index aa1faf95..07cd1e37 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt @@ -84,7 +84,7 @@ class FirstSignInNavigationTest { coEvery { views.getUserViews(userId = user.id) } returns successQueryResult(listOf(movies, shows)) for ((library, kind, count) in listOf(Triple(movies, BaseItemKind.MOVIE, 6), Triple(shows, BaseItemKind.SERIES, 29))) { coEvery { items.getItems(userId = user.id, parentId = library.id, recursive = true, - includeItemTypes = listOf(kind), fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.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( List(count) { BaseItemDto(id = UUID.randomUUID(), name = "${library.name} title $it", type = kind) }) } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/HomeRecoveryTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/HomeRecoveryTest.kt index dc0b780d..128dae1e 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/HomeRecoveryTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/HomeRecoveryTest.kt @@ -43,11 +43,13 @@ class HomeRecoveryTest { val started = CompletableDeferred() val response = CompletableDeferred() val facade = mockk() - 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 refresh = async(Dispatchers.IO) { model.loadLibrary(server, personA, viewerA, retry = true) } started.await() assertEquals(film.id, model.snapshot.rows.first().cards.first().id) + assertFalse(model.snapshot.rows.first().watchReady) response.completeExceptionally(IOException("fixture offline")) refresh.await() assertFalse(model.snapshot.jellyfinAvailable) @@ -62,8 +64,9 @@ class HomeRecoveryTest { cache.write("${server.id}:${personA.id}", library, 1000L) val startedA = CompletableDeferred() val responseA = CompletableDeferred() - coEvery { facade.read(personA.id) } coAnswers { startedA.complete(Unit); responseA.await() } - coEvery { facade.read(personB.id) } throws IOException("fixture offline") + coEvery { facade.readWatch(personA.id) } coAnswers { startedA.complete(Unit); responseA.await() } + 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 requestA = async(Dispatchers.IO) { model.loadLibrary(server, personA, viewerA) } startedA.await() @@ -80,7 +83,8 @@ class HomeRecoveryTest { @Test fun readableJellyfinDoesNotBecomeAnOutageWhenLocalCacheCannotBeWritten() = runBlocking { val facade = mockk() - coEvery { facade.read(personA.id) } returns library + coEvery { facade.readWatch(personA.id) } returns library + coEvery { facade.readLibrary(any()) } returns HomeLibrary() val cache = mockk() every { cache.read(any()) } returns null every { cache.write(any(), any(), any()) } throws IOException("fixture disk full") @@ -93,7 +97,8 @@ class HomeRecoveryTest { @Test fun renamedHouseholdProfileKeepsItsOwnJellyfinLibraryWithoutAConductarrIdentity() = runBlocking { val facade = mockk() - coEvery { facade.read(personA.id) } returns library + coEvery { facade.readWatch(personA.id) } returns library + coEvery { facade.readLibrary(any()) } returns HomeLibrary() val cache = mockk(relaxed = true) every { cache.read(any()) } returns null val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore())) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/LibraryReadFacadeTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/LibraryReadFacadeTest.kt index 92afbe07..a2fb8b0d 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/LibraryReadFacadeTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/LibraryReadFacadeTest.kt @@ -51,7 +51,7 @@ class LibraryReadFacadeTest { val offsets = mutableListOf() coEvery { items.getItems(userId = user, parentId = library.id, recursive = true, includeItemTypes = listOf(BaseItemKind.MOVIE), - fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.SORT_NAME, ItemFields.DATE_CREATED), + fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.DATE_CREATED), sortBy = listOf(ItemSortBy.SORT_NAME), startIndex = capture(offsets), limit = 200) } coAnswers { if (offsets.last() == 0) successQueryResult(firstChunk, 202) else { assertEquals(200, offsets.last()) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/LibrarySnapshotStoreTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/LibrarySnapshotStoreTest.kt index 279c3966..4e2917c3 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/LibrarySnapshotStoreTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/LibrarySnapshotStoreTest.kt @@ -48,6 +48,21 @@ class LibrarySnapshotStoreTest { 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 { val partial = listOf(LibraryTitle("partial", "First film", "movie")) val complete = partial + LibraryTitle("last", "Last film", "movie") diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/PersonPickerSessionTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonPickerSessionTest.kt index 8e13dca4..95fcbb52 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/PersonPickerSessionTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonPickerSessionTest.kt @@ -128,6 +128,8 @@ class PersonPickerSessionTest { private fun awaitLoaded(user: JellyfinUser) { try { rule.waitUntil(10_000) { + // Setup navigation writes Compose state asynchronously; publish it before pumping replacement composition. + androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() Shadows.shadowOf(Looper.getMainLooper()).idle() models.lastOrNull()?.signedInPerson == user.name!!.lowercase() && models.last().conductarr.identity.value?.signedIn == true && diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/PersonSwitchThreadingTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonSwitchThreadingTest.kt index 20ea6d9b..6a5936f8 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/PersonSwitchThreadingTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonSwitchThreadingTest.kt @@ -214,8 +214,9 @@ class PersonSwitchThreadingTest { every { repository.currentUser } returns user every { repository.currentServer } returns server val home = mockk() - 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))) + coEvery { home.readLibrary(user.id) } returns org.justinvisser.wolfskin.design.HomeLibrary() val cache = mockk(relaxed = true) every { cache.read(any()) } returns null val homeModel = WolfskinHomeViewModel(context, home, cache, mockk(relaxed = true), WolfskinSeeAllLists(), conductarr) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/PlaybackUserData.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/PlaybackUserData.kt new file mode 100644 index 00000000..b8e86ca0 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/PlaybackUserData.kt @@ -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) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/PlayerHandoffTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/PlayerHandoffTest.kt index 92c0a248..dca356a7 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/PlayerHandoffTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/PlayerHandoffTest.kt @@ -58,7 +58,7 @@ class PlayerHandoffTest { @Test fun failedPlaybackReadLeavesSavedHomeFocusedAndDrawsTheExactOfflineToast() { val snapshot = HomeFixture(rule.activity).snapshot("mindel", "offline") 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 { ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { modifier -> HomeScreen(shell, snapshot, modifier) { model.act(it, shell, server, user) } @@ -80,14 +80,16 @@ class PlayerHandoffTest { @Test fun successfulPlaybackUsesTheRealFullscreenDestinationAndBackKeepsTheResumeCard() { val title = HomeFixture(rule.activity).snapshot("mindel").rows.first().cards.first() 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 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 } val destination = navigation.backStack.last() as Destination.Playback assertEquals(id, destination.itemId) - assertEquals((title.resumeSeconds * 1000).toLong(), destination.positionMs) + assertEquals(1_136_274L, destination.positionMs) assertTrue(destination.fullScreen) navigation.goBack() assertTrue(navigation.backStack.single() is Destination.Home) @@ -99,11 +101,11 @@ class PlayerHandoffTest { @Test fun anotherPersonsSessionCannotResolveOrStartPlayback() { val title = HomeFixture(rule.activity).snapshot("mindel").rows.first().cards.first() 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 } assertEquals(RecoveryMessage.PlaybackUnavailable, model.toast) 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) { rule.waitUntil(5000) { diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/SavedPeoplePickerTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/SavedPeoplePickerTest.kt new file mode 100644 index 00000000..1fb29d31 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/SavedPeoplePickerTest.kt @@ -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() + + @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(relaxed = true) + coEvery { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, users) + val libraries = mockk(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) + } + } + } + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt index 6df8426d..0b6dac33 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt @@ -11,6 +11,9 @@ import androidx.compose.ui.test.pressKey import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performMouseInput 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.semantics.SemanticsActions 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 replay(end: String): Int { val path = paths.single { it.getString("end") == end } @@ -312,11 +336,6 @@ class TitleKeysTest { 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() { render(transform = { data -> diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitlePlaybackNavigationTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitlePlaybackNavigationTest.kt new file mode 100644 index 00000000..75c92ed5 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitlePlaybackNavigationTest.kt @@ -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() + coEvery { facade.readSaved(any(), any()) } returns null + coEvery { facade.read(any(), any(), any()) } coAnswers { + val id = firstArg() + 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(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() + coEvery { facade.readSaved(any(), any()) } returns null + coEvery { facade.read(any(), any(), any()) } coAnswers { + TitlePageData(series, first, watchTarget = if (firstArg() == 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(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() + } + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleReadFacadeTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleReadFacadeTest.kt index fde2ff9c..7ea534e6 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleReadFacadeTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleReadFacadeTest.kt @@ -33,6 +33,8 @@ import com.github.damontecres.wholphin.test.TestTracks import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.request.GetEpisodesRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest +import org.jellyfin.sdk.model.api.ItemFields +import org.jellyfin.sdk.model.api.request.GetNextUpRequest import org.junit.Assert.* import org.junit.After import org.junit.Before @@ -128,7 +130,7 @@ class TitleReadFacadeTest { } @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) coEvery { library.getItem(any()) } throws java.io.IOException("offline") val saved = facade.readSaved(firstId, preferences)!! @@ -139,6 +141,7 @@ class TitleReadFacadeTest { assertEquals(TitleConnection.SignedOut, saved.connection) assertTrue(saved.item.ratings.isEmpty()) assertEquals(firstId.toString(), saved.watchTarget.id) + assertFalse(screen.options.any { it.id == "play" || it.id == "restart" }) } @Test fun diskCopiesNeverCrossViewerIdentity() = runTest { @@ -218,6 +221,43 @@ class TitleReadFacadeTest { assertEquals(2, TitleState(data, ShellPerson("justin", "Justin", "J", false)) {}.season) } + @Test fun leanEpisodeListsKeepSingleItemPlaybackTracksEvenWithoutNextUp() = runTest { + val series = BaseItemDto(id = seriesId, name = "Series", type = BaseItemKind.SERIES, people = emptyList(), providerIds = mapOf("Tmdb" to "42")) + val episode = file(firstId, BaseItemKind.EPISODE).copy(seriesId = seriesId, parentIndexNumber = 3, indexNumber = 1) + coEvery { library.getItem(seriesId) } returns successResponse(series) + coEvery { library.getItem(firstId) } returns successResponse(episode) + coEvery { shows.getNextUp(any()) } coAnswers { + assertTrue(firstArg().fields.orEmpty().isEmpty()) + successQueryResult(emptyList()) + } + coEvery { items.getItems(any()) } returns successQueryResult( + listOf(BaseItemDto(id = seasonId, type = BaseItemKind.SEASON, indexNumber = 3))) + coEvery { shows.getEpisodes(any()) } coAnswers { + val fields = firstArg().fields.orEmpty() + assertFalse(fields.any { it in listOf(ItemFields.MEDIA_SOURCES, ItemFields.MEDIA_STREAMS, ItemFields.PEOPLE, ItemFields.PARENT_ID) }) + successQueryResult(listOf(episode.copy(mediaSources = null, overview = "Episode synopsis"))) + } + val page = facade.read(seriesId, 3, preferences) + assertEquals(firstId.toString(), page.playTarget?.id) + assertEquals("MKV", page.versions.single().container) + assertEquals(listOf(3), page.versions.single().audio.map { it.index }) + val saved = requireNotNull(facade.readSaved(seriesId, preferences)) + assertEquals(page.versions, saved.versions) + } + + @Test fun performerCreditListsOmitStreamsButKeepCharacterRoles() = runTest { + coEvery { library.getItem(firstId) } returns successResponse( + BaseItemDto(id = firstId, type = BaseItemKind.PERSON, name = "Actor")) + coEvery { items.getItems(any()) } coAnswers { + val fields = firstArg().fields.orEmpty() + assertFalse(fields.any { it in listOf(ItemFields.MEDIA_SOURCES, ItemFields.MEDIA_STREAMS, ItemFields.PARENT_ID) }) + assertTrue(ItemFields.PEOPLE in fields) + successQueryResult(listOf(file(secondId).copy(people = listOf( + org.jellyfin.sdk.model.api.BaseItemPerson(id = firstId, name = "Actor", role = "Chef"))))) + } + assertEquals("Chef", facade.readPerson(firstId, 0).credits.single().character) + } + @Test fun compactJellyfinSourceIdUsesTheSameVersionPersistenceKey() = runTest { coEvery { library.getItem(firstId) } returns successResponse(file(firstId)) coEvery { playback.savePlayVersion(any(), any()) } returns null diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReadTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReadTest.kt new file mode 100644 index 00000000..a7901727 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReadTest.kt @@ -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() + private val library = mockk() + private val shows = mockk() + private val items = mockk() + private val images = mockk(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 { + every { currentUser } returns mockk { every { id } returns userId } + } + val title = TitleReadFacade(api, images, mockk(), repository, mockk(), mockk()) + 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() + coEvery { facade.readLibrary(userId) } coAnswers { releaseLibrary.await(); throw java.io.IOException("Library failed") } + val cache = mockk(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() + } + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReturnTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReturnTest.kt new file mode 100644 index 00000000..494c8412 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReturnTest.kt @@ -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() + + @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() + 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(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) + } + } +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrPreference.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrPreference.kt index 559b4365..b794391b 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrPreference.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrPreference.kt @@ -21,6 +21,7 @@ import kotlinx.coroutines.CoroutineScope import org.justinvisser.wolfskin.conductarr.CacheEntry import org.justinvisser.wolfskin.conductarr.CacheStore import org.justinvisser.wolfskin.conductarr.FileCacheStore +import org.justinvisser.wolfskin.conductarr.PersistentDns /** Advanced-only endpoint and fixture controls contain no credentials. */ object ConductarrPreference { @@ -54,8 +55,8 @@ object ConductarrPreference { object ConductarrModule { @Provides @Singleton - fun session(@ApplicationContext context: Context, @IoCoroutineScope scope: CoroutineScope): ConductarrSession = - ConductarrSession(ConductarrDiskCache(context), scope) + fun session(@ApplicationContext context: Context, @IoCoroutineScope scope: CoroutineScope, dns: PersistentDns): ConductarrSession = + ConductarrSession(ConductarrDiskCache(context), scope, dns) } /** Android may create noBackupFilesDir, so resolve it only during an IO cache operation. */ diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrSession.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrSession.kt index 5ba52caa..ce639277 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrSession.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrSession.kt @@ -29,6 +29,7 @@ data class ConductarrIdentity( class ConductarrSession( private val cache: CacheStore, private val disposalScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO), + private val dns: PersistentDns? = null, ) : Closeable { private val mutableIdentity = MutableStateFlow(null) val identity: StateFlow = mutableIdentity.asStateFlow() @@ -50,7 +51,7 @@ class ConductarrSession( } 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) { mutableIdentity.update { if (generation == currentGeneration) it?.copy(operator = null, signedIn = false, error = ClientError.NeedsSignIn) else it diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt index c8ab24cc..35f10b1e 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt @@ -18,10 +18,12 @@ import java.util.Date import java.util.Locale import javax.inject.Inject import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers +import com.github.damontecres.wholphin.util.WholphinDispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.coroutines.coroutineScope +import org.justinvisser.wolfskin.tv.read.playbackPositionMs import org.justinvisser.wolfskin.design.* import org.justinvisser.wolfskin.tv.read.HomeLibraryCache 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) { 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) { - withContext(Dispatchers.IO) { + withContext(WholphinDispatchers.IO) { conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true) } } @@ -74,11 +76,12 @@ class WolfskinHomeViewModel @Inject constructor( toast = null 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 var libraryCopy = saved?.first ?: HomeLibrary() var libraryAvailable = true var librarySavedAt: String? = null + var watchReady = false val identity = conductarr.identity.value var catalogueCopy = DiscoverSnapshot(status = if (identity?.signedIn == true || (retry && identity != null)) DiscoverStatus.Loading else DiscoverStatus.NeedsSignIn) fun publish() { @@ -89,8 +92,9 @@ class WolfskinHomeViewModel @Inject constructor( DiscoverStatus.Unavailable -> HomeAvailability.Unavailable else -> if (catalogueCopy.savedAt != null) HomeAvailability.Saved else HomeAvailability.Ready } - snapshot = HomeSnapshot(buildHomeRows(libraryCopy, catalogueCopy.homeRows(), libraryAvailable = libraryAvailable), - status, libraryAvailable, catalogueCopy.savedAt ?: librarySavedAt) + snapshot = HomeSnapshot(buildHomeRows(libraryCopy, catalogueCopy.homeRows(), libraryAvailable = libraryAvailable).map { + if (it.kind == HomeRowKind.Hero) it.copy(watchReady = watchReady) else it + }, status, libraryAvailable, catalogueCopy.savedAt ?: librarySavedAt) } catalogueSnapshot = catalogueCopy publishCatalogue = { value -> if (revision == loadRevision && sourceScope == scope) { catalogueCopy = value; catalogueSnapshot = value; publish() } } @@ -107,20 +111,43 @@ class WolfskinHomeViewModel @Inject constructor( if (revision == loadRevision && conductarr.identity.value?.client === identity.client) publishCatalogue?.invoke(catalogue) } } - try { - val library = withContext(Dispatchers.IO) { home.read(user.id) } - val savedAt = System.currentTimeMillis() - libraryCopy = library - publish() - withContext(Dispatchers.IO) { try { cache.write(scope, library, savedAt) } catch (_: java.io.IOException) { /* Saving cannot delay first paint or turn a reachable source into an outage. */ } } - } catch (exception: Exception) { - if (exception is CancellationException) throw exception - if (revision != loadRevision) return - libraryAvailable = false - librarySavedAt = saved?.second?.let { SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(it)) } - publish() - if (retry) showToast(RecoveryMessage.StillUnreachable) + 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 { + val watch = withContext(WholphinDispatchers.IO) { home.readWatch(user.id) } + if (revision != loadRevision) return@launch + libraryCopy = libraryCopy.copy(resume = watch.resume, nextUp = watch.nextUp) + watchReady = true + libraryAvailable = true + publish() + } catch (exception: Exception) { + if (exception is CancellationException) throw exception + if (revision != loadRevision) return@launch + libraryAvailable = false + librarySavedAt = saved?.second?.let { SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(it)) } + publish() + if (retry) showToast(RecoveryMessage.StillUnreachable) + } + } + launch { + try { + val shelves = withContext(WholphinDispatchers.IO) { home.readLibrary(user.id) } + if (revision != loadRevision) return@launch + libraryCopy = libraryCopy.copy(newest = shelves.newest, destinations = shelves.destinations, destinationTotals = shelves.destinationTotals) + publish() + } catch (exception: Exception) { + if (exception is CancellationException) throw exception + if (revision == loadRevision && retry) showToast(RecoveryMessage.StillUnreachable) + } + } + } + if (revision == loadRevision) save() } suspend fun readAhead(lane: String?) { @@ -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.Details -> navigation.navigateTo(Destination.Wolfskin(titleRoute(action.title.id))) is HomeAction.Play -> viewModelScope.launch { + val personKey = shell.person.key val selectedId = shell.person.jellyfinUserId 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) @@ -146,9 +174,9 @@ class WolfskinHomeViewModel @Inject constructor( return@launch } try { - val id = withContext(Dispatchers.IO) { home.playableId(user.id, action.title) } - if (id == null) showToast(RecoveryMessage.PlaybackUnavailable) - else navigation.navigateTo(Destination.Playback(id, if (action.resume) (action.title.resumeSeconds * 1000).toLong() else 0L)) + val item = withContext(WholphinDispatchers.IO) { home.preparePlayback(user.id, action.title) } + if (item == null) showToast(RecoveryMessage.PlaybackUnavailable) + else if (shell.person.key == personKey && (sourceScope == null || sourceScope == "${server.id}:${user.id}")) navigation.navigateTo(Destination.Playback(item.id, item.playbackPositionMs())) } catch (exception: Exception) { if (exception is CancellationException) throw exception showToast(RecoveryMessage.PlaybackUnavailable) @@ -204,7 +232,8 @@ internal fun HomeTitle.libraryTitle(): LibraryTitle = LibraryTitle( @Composable fun HomeDestination(shell: ShellState, server: JellyfinServer, user: JellyfinUser, modifier: Modifier = Modifier, model: WolfskinHomeViewModel = hiltViewModel()) { 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") LaunchedEffect(view.row, model.snapshot.rows) { val lane = model.snapshot.rows.drop(view.row).take(3).lastOrNull { it.catalogue }?.id diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/NetworkStartup.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/NetworkStartup.kt new file mode 100644 index 00000000..899341e5 --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/NetworkStartup.kt @@ -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, + 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. + } + } +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt index e457aa86..4db5512d 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt @@ -48,6 +48,7 @@ import org.justinvisser.wolfskin.design.TitleReadStatus import org.justinvisser.wolfskin.design.TitleState import org.justinvisser.wolfskin.design.TitleSurface import org.justinvisser.wolfskin.tv.read.TitleReadFacade +import org.justinvisser.wolfskin.tv.read.playbackPositionMs import java.util.UUID import javax.inject.Inject @@ -79,6 +80,7 @@ class WolfskinTitleViewModel @Inject constructor( private val saveMutex = Mutex() private var performerPage = 1 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 popRoute() = navigation.goBack() @@ -87,6 +89,16 @@ class WolfskinTitleViewModel @Inject constructor( /** 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) { 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 loadJob?.cancel() person = null @@ -132,7 +144,7 @@ class WolfskinTitleViewModel @Inject constructor( if (title !== current) return if (heldId != null) { 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) } } catch (exception: Exception) { @@ -223,13 +235,21 @@ class WolfskinTitleViewModel @Inject constructor( when (action) { 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.OpenTitle -> navigation.navigateTo(action.item.id.toUuidOrNull()?.let { Destination.MediaItem(it, when (action.item.kind) { - "movie" -> org.jellyfin.sdk.model.api.BaseItemKind.MOVIE - "episode" -> org.jellyfin.sdk.model.api.BaseItemKind.EPISODE - else -> org.jellyfin.sdk.model.api.BaseItemKind.SERIES - }) } ?: Destination.Wolfskin(titleRoute(action.item.id))) - is TitleAction.Play -> play(action.item.id.toUuidOrNull(), if (action.restart) 0 else action.item.resumeSeconds * 1000) - is TitleAction.Trailer -> if (action.url.startsWith("jellyfin:")) play(action.url.removePrefix("jellyfin:").toUuidOrNull(), 0) + 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 + "episode" -> org.jellyfin.sdk.model.api.BaseItemKind.EPISODE + else -> org.jellyfin.sdk.model.api.BaseItemKind.SERIES + }) } ?: Destination.Wolfskin(titleRoute(action.item.id)) + if (action.episodeSelection && title?.data?.watchTarget?.kind == "episode") navigation.replaceTop(destination) + 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." is TitleAction.Version, is TitleAction.Track, is TitleAction.Watched -> savePlayback(action) 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 { try { if (id == null) throw IllegalArgumentException("No Jellyfin playback identity") - facade.preparePlayback(id) - navigation.navigateTo(Destination.Playback(id, position)) + val item = facade.preparePlayback(id) ?: throw IllegalArgumentException("No playable item") + if (title !== current || watchScope != playbackScope) return@launch + navigation.navigateTo(Destination.Playback(item.id, if (restart) 0L else item.playbackPositionMs())) } catch (exception: Exception) { if (exception is CancellationException) throw exception title?.toast = RecoveryMessage.PlaybackUnavailable @@ -308,7 +331,8 @@ class WolfskinTitleViewModel @Inject constructor( @Composable 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() - 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 requests = if (route.startsWith("title/")) rememberTitleRequests(viewModel, shell, route, identity, fixtures, title) else null LaunchedEffect(title?.data?.status) { diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WatchRefresh.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WatchRefresh.kt new file mode 100644 index 00000000..fb44b681 --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WatchRefresh.kt @@ -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 { + 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 +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinLibrary.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinLibrary.kt index 0301d3dc..c73a68ee 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinLibrary.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinLibrary.kt @@ -72,7 +72,8 @@ fun WolfskinLibraryContent( ShellPlaceholder("See all list unavailable", modifier) 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)) { val wasLoading = grid.status == LibraryStatus.Loading if (wasLoading && !shell.navFocused) shell.focusNav(shell.currentTab) @@ -82,7 +83,7 @@ fun WolfskinLibraryContent( grid.status = LibraryStatus.Ready } else { 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 grid.source = snapshot.items focusedId?.let { id -> grid.items.indexOfFirst { it.id == id }.takeIf { it >= 0 }?.let { grid.index = it } } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeReadFacade.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeReadFacade.kt index 1867ae60..bb86e68a 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeReadFacade.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeReadFacade.kt @@ -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. */ class HomeReadFacade @Inject constructor(private val api: ApiClient, private val images: ImageUrlService, private val libraries: LibraryReadFacade) { 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 nextItems = api.tvShowsApi.getNextUp(userId = userId, fields = FIELDS, limit = 20, enableUserData = true).content.items 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 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 destinations = linkedMapOf>() val newest = mutableListOf() @@ -46,23 +52,18 @@ class HomeReadFacade @Inject constructor(private val api: ApiClient, private val 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) }) } - 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? { - title.id.let { runCatching { UUID.fromString(it) }.getOrNull() }?.let { id -> - val item = api.userLibraryApi.getItem(userId = userId, itemId = id).content - 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) - return api.itemsApi.getItems(userId = userId, recursive = true, searchTerm = title.title, includeItemTypes = kinds, fields = FIELDS, limit = 20).content.items.firstOrNull { - it.name == title.title && (!title.isEpisode || (it.seriesName == title.seriesTitle && it.parentIndexNumber == title.season && it.indexNumber == title.episode)) - }?.id + /** Resolve the current checkpoint even when the hero was painted from disk. */ + suspend fun preparePlayback(userId: UUID, title: HomeTitle): BaseItemDto? { + val id = runCatching { UUID.fromString(title.id) }.getOrNull() ?: run { + val kinds = if (title.isEpisode) listOf(BaseItemKind.EPISODE) else listOf(BaseItemKind.MOVIE) + api.itemsApi.getItems(userId = userId, recursive = true, searchTerm = title.title, includeItemTypes = kinds, fields = FIELDS, limit = 20).content.items.firstOrNull { + it.name == title.title && (!title.isEpisode || (it.seriesName == title.seriesTitle && it.parentIndexNumber == title.season && it.indexNumber == title.episode)) + }?.id + } ?: return null + return readPlayback(api, userId, id) } private fun title(item: BaseItemDto, series: Map = emptyMap()): HomeTitle { diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibraryReadFacade.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibraryReadFacade.kt index 775235f2..3c1d01ce 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibraryReadFacade.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibraryReadFacade.kt @@ -72,7 +72,7 @@ class LibraryReadFacade @Inject constructor(private val api: ApiClient) { val response = api.itemsApi.getItems( userId = userId, parentId = library.id, recursive = true, includeItemTypes = listOf(if (destination == "movies") BaseItemKind.MOVIE else BaseItemKind.SERIES), - fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.SORT_NAME, ItemFields.DATE_CREATED), + fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.DATE_CREATED), sortBy = listOf(ItemSortBy.SORT_NAME), startIndex = offset, limit = WolfskinTokens.Behaviour.libraryChunk.toInt(), ).content diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibrarySnapshotStore.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibrarySnapshotStore.kt index 85395ba0..fffe1f2c 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibrarySnapshotStore.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibrarySnapshotStore.kt @@ -19,13 +19,13 @@ data class LibrarySnapshot(val items: List, val savedAtMillis: Lon /** 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) { - fun read(scope: String, destination: String, force: Boolean = false, load: () -> Flow>): Flow = flow { + fun read(scope: String, destination: String, force: Boolean = false, invalidatedAtMillis: Long = 0L, load: () -> Flow>): Flow = flow { val key = "library:$destination" val saved = try { cache.read(scope, key) } catch (_: IOException) { null } val titles = saved?.let { try { decode(it.body) } catch (_: JSONException) { null } } if (saved != null && titles != null) emit(LibrarySnapshot(titles, saved.savedAtMillis, false)) 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 = emptyList() try { load().collect { chunk -> diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/PlaybackRead.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/PlaybackRead.kt new file mode 100644 index 00000000..ad17d57b --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/PlaybackRead.kt @@ -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 diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/TitleReadFacade.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/TitleReadFacade.kt index 4b3a3a4a..1530b8df 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/TitleReadFacade.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/TitleReadFacade.kt @@ -35,6 +35,7 @@ import org.justinvisser.wolfskin.design.TitlePageData import org.justinvisser.wolfskin.design.TitlePerson import org.justinvisser.wolfskin.design.TitleTrack import org.justinvisser.wolfskin.design.TitleVersion +import org.justinvisser.wolfskin.design.WolfskinTokens import java.io.File import java.util.Locale import java.util.UUID @@ -52,7 +53,8 @@ class TitleReadFacade @Inject constructor( @param:ApplicationContext private val context: Context, ) { 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. */ 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 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) - .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 } } @@ -87,12 +89,12 @@ class TitleReadFacade @Inject constructor( val series = if (opened.type == BaseItemKind.EPISODE && opened.seriesId != null) readItem(opened.seriesId!!) else opened val seasons = if (series.type == BaseItemKind.SERIES) api.itemsApi.getItems(GetItemsRequest(parentId = series.id, includeItemTypes = listOf(BaseItemKind.SEASON), recursive = false, sortBy = listOf(ItemSortBy.INDEX_NUMBER))).content.items.orEmpty() else emptyList() val next = if (opened.type == BaseItemKind.EPISODE || opened.type == BaseItemKind.MOVIE) opened else { - api.tvShowsApi.getNextUp(GetNextUpRequest(seriesId = series.id, userId = repository.currentUser?.id, limit = 1, 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 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 target = next ?: episodes.firstOrNull { it.type == BaseItemKind.EPISODE } + val episodes = selected?.let { api.tvShowsApi.getEpisodes(GetEpisodesRequest(seriesId = series.id, seasonId = it.id, fields = episodeFields)).content.items.orEmpty() }.orEmpty() + val target = next ?: episodes.firstOrNull { it.type == BaseItemKind.EPISODE }?.let { readItem(it.id) } val rows = (listOf(series, opened) + listOfNotNull(target) + episodes).distinctBy { it.id } val numbers = seasons.mapNotNull { it.indexNumber } val result = build(rows, numbers, selected?.indexNumber, opened.id.toString(), target?.id?.toString(), preferences) @@ -121,8 +123,9 @@ class TitleReadFacade @Inject constructor( 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. */ - suspend fun preparePlayback(id: UUID) { api.userLibraryApi.getItem(id) } + /** Series actions re-resolve NextUp; explicit episodes and Restart retain their exact identity. */ + suspend fun preparePlayback(id: UUID): BaseItemDto? = + readPlayback(api, repository.currentUser?.id, id) /** Track selection uses the same repository consumed by the upstream player. */ suspend fun apply(action: TitleAction) = withContext(Dispatchers.IO) { @@ -163,7 +166,7 @@ class TitleReadFacade @Inject constructor( } else emptyList() val person = previous.firstOrNull() ?: api.userLibraryApi.getItem(id).content val credits = api.itemsApi.getItems(GetItemsRequest(personIds = listOf(id), recursive = true, - includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), fields = 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() val rows = listOf(person) + (previous.drop(1) + credits).distinctBy { it.id } 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.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) }, - rows.first { it.id.toString() == watchId }.let { mapItem(it) }, versions, chosen?.source?.id, chosen?.audioStream?.stream?.index, + val pageItem = mapItem(item).copy(seasons = seasonNumbers.size) + 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, item.remoteTrailers.orEmpty().firstNotNullOfOrNull { it.url }, 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) }) }, - 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) } - 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.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) }, - 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, 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(), ) + 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 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())) diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/ConductarrClient.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/ConductarrClient.kt index 5559f15a..8030fcb9 100644 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/ConductarrClient.kt +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/ConductarrClient.kt @@ -67,6 +67,7 @@ class ConductarrClient( timeouts: ClientTimeouts = ClientTimeouts(), private val nowMillis: () -> Long = System::currentTimeMillis, private val onEvent: (ClientEvent) -> Unit = {}, + dns: PersistentDns? = null, ) : Closeable { private val base = baseUrl.toHttpUrl() private val scope: String @@ -74,6 +75,7 @@ class ConductarrClient( private val cacheIdentityLock = Any() private val catalogueReads = Semaphore(2) private val http = OkHttpClient.Builder() + .apply { if (dns != null) DnsConnectionRetry.configure(this, dns) } .connectTimeout(timeouts.connectSeconds, TimeUnit.SECONDS) .readTimeout(timeouts.readSeconds, TimeUnit.SECONDS) .callTimeout(timeouts.callSeconds, TimeUnit.SECONDS) diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/DnsConnectionRetry.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/DnsConnectionRetry.kt new file mode 100644 index 00000000..4248a4c0 --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/DnsConnectionRetry.kt @@ -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() + + 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) + } + } +} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/PersistentDns.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/PersistentDns.kt new file mode 100644 index 00000000..2c65b657 --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/PersistentDns.kt @@ -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, + val savedAtMillis: Long, + @Transient val resolvedAddresses: List = decodeAddresses(addresses), + ) + private val hosts = ConcurrentHashMap() + private val diskLock = Any() + @Volatile private var loaded = false + + override fun lookup(hostname: String): List { + 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 { + 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 { + 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>(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): List { + 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) + } + } + } +} diff --git a/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/ClientSmoke.kt b/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/ClientSmoke.kt index 98173fce..8c8495fb 100644 --- a/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/ClientSmoke.kt +++ b/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/ClientSmoke.kt @@ -2,6 +2,10 @@ package org.justinvisser.wolfskin.conductarr import java.nio.file.Files +import java.io.File +import java.net.InetAddress +import java.util.concurrent.Executor +import okhttp3.Dns import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.toList import kotlinx.coroutines.runBlocking @@ -14,13 +18,24 @@ fun main() = runBlocking { try { MockWebServer().use { server -> 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()) - 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 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 check(saved.saved.fromDisk) server.enqueue(MockResponse.Builder().code(304).build()) diff --git a/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/DnsConnectionRetryTest.kt b/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/DnsConnectionRetryTest.kt new file mode 100644 index 00000000..58b72d98 --- /dev/null +++ b/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/DnsConnectionRetryTest.kt @@ -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() + val message = CompletableFuture() + val uncaught = AtomicReference() + 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() + } + } + } +} diff --git a/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/PersistentDnsTest.kt b/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/PersistentDnsTest.kt new file mode 100644 index 00000000..b167b354 --- /dev/null +++ b/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/PersistentDnsTest.kt @@ -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() + 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() + } + } + } +} diff --git a/data/tv-spec/spec.md b/data/tv-spec/spec.md new file mode 100644 index 00000000..4fb76581 --- /dev/null +++ b/data/tv-spec/spec.md @@ -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. diff --git a/data/tv-v2/feedback-2026-11-06/delta.json b/data/tv-v2/feedback-2026-11-06/delta.json new file mode 100644 index 00000000..5e732f73 --- /dev/null +++ b/data/tv-v2/feedback-2026-11-06/delta.json @@ -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" +} diff --git a/data/tv-v2/feedback-2026-11-06/index.html b/data/tv-v2/feedback-2026-11-06/index.html new file mode 100644 index 00000000..7b715570 --- /dev/null +++ b/data/tv-v2/feedback-2026-11-06/index.html @@ -0,0 +1,36 @@ + + + + +Wolfskin TV · feedback delta · revision 5 + +

Wolfskin TV · feedback delta

+

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.

+ +

Who’s watching

Gear centred beneath everyone, with the person-style focus ring.

+Native picker after: Settings centred below the complete people row +
BeforeNative picker before: gear below the final person
+

Home

Approved edge scrolling retained. Short/long poster, tile and wide rows, row changes and native title Back exercise the same visibility contract.

+Native Home after: focused card safely inside a long shelf +
BeforeNative Home baseline
+
Real-source hero request comparison

Same native renderer and source artwork: old 720p response, then panel-sized response. Intentionally blurred poster washes are unchanged.

Native hero using the old 720p source responseNative hero using the panel-resolution source response
+

Discover

60 px from lane title to cards; all lanes use the same 104 px heading band.

+Native Discover after: more space above every lane's cards +
BeforeNative Discover before: smaller heading gap
+

Series header

Episode-context summary and a measured header instead of the empty fixed-height spacer. Season chips and thumbnail artwork enter the initial view.

+Native Bear series header after: episode overview and visible thumbnail row +
BeforeNative Bear series header before: show overview and empty space before seasons
+

Episode shelf

Real episode Primary artwork, 400×280 px cards and the focused episode's own overview. Cast retains its Level 2 clearance.

+Native Bear episodes after: taller thumbnail cards, focused overview and Cast below +
BeforeNative Bear episodes before: monitor placeholders
+

Person

Six full-width poster columns; no shrinking of the final column.

+Native Person after +
BeforeNative Person before
+ diff --git a/docker/README.md b/docker/README.md index f2fd9935..2c1c4c56 100644 --- a/docker/README.md +++ b/docker/README.md @@ -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 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//binary` in each module) before +each run and record actual class-start order; XML file listing order is not +execution order. Keep worker heap and parallelism unchanged between samples. +`PersonPickerSessionTest` must also pass late in the full app JVM, not just alone +or promoted to the front by previous failures. + +## Saved-person launch and DNS + +Launch restores an unprotected saved Jellyfin login from local storage before +network validation. The existing Wolfskin picker paints the saved names and +identity-derived colors, then refreshes the household in the background. PIN and +require-login profiles still go through the existing protected sign-in flow. +`Users/Me` and `System/Info/Public` now start concurrently; a late launch response +cannot replace a different selected person. Name revalidation updates only the +name column and reloads the saved user, preserving newer settings and PINs. + +Jellyfin, Conductarr, artwork, playback and crash-report clients share one +`PersistentDns` instance through the application graph. It keeps system-ordered +IPv6/IPv4 answers in memory and `noBackupFilesDir/wolfskin-dns.json` (at most 32 hosts). +Saved answers return immediately for up to 24 hours and refresh off main after +60 seconds; failed background lookups back off for 2 seconds without extending +the last successful timestamp. Unexpected resolver exceptions are contained at +the background refresh and warm-up boundaries. Negative answers are not persisted or cached. +Missing, oversized or corrupt hint files fall back to system resolution. +Application creation warms the saved Jellyfin host and configured Conductarr +host on parallel IO jobs; it does not change Android Private DNS. + +A connection-establishment failure before any HTTP request headers were sent +gets one fresh system lookup and one retry. Responses, read failures and +uncertain mutation outcomes are not replayed. Conductarr retains its existing +no-automatic-replay policy after a request starts. A DNS refresh changes the next +new connection; already-working pooled connections are not forcibly closed. +OkHttp's derived WebSocket clients disable event listeners; those calls bypass +the HTTP retry bookkeeping while retaining the shared DNS resolver. + +Device acceptance (not established by JVM tests): with saved people present, +cold-launch six times more than 60 seconds apart and require the picker within +0.5 seconds on every launch. Press Left/Right then one OK; the selected Home must +open without a second picker. Open the avatar picker, change person with one OK, +and repeat after an offline launch to confirm saved people still paint. + +Compare timestamps of the launch pair of SDK log lines +`GET https://jelly.justinvisser.org/Users/Me` and +`GET https://jelly.justinvisser.org/System/Info/Public` (ignore later foreground +`Users/Me` refreshes). The spike's two slow launch gaps were 1.508 and 1.509 +seconds. The requests now overlap, so either may log first; the picker must not +wait for either response. The minified SDK log tag is not stable across builds. + ## Optimized side-by-side comparison The `optimized` build type inherits `release`: R8 optimization/minification, @@ -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 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 Coming-in sheet described below. **Advanced settings → Wolfskin screens** is the persistent 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` release/repeat guard and Back ladder. -`HomeReadFacade` is the only Home code importing Jellyfin API clients. Release -variants read the selected user's Resume, Next up, newest titles and destination -shelves, with app-private last-known-good projections through the shared atomic -disk cache. A missing saved library becomes a recovery panel; a saved library -stays visible when Jellyfin is unreachable. Playback resolves an actual film or -episode and checks Jellyfin before opening upstream's player. No season, -recommendation stub or different person's session is promoted to playback. +`HomeReadFacade` confines Home SDK reads to the selected user. Resume and Next up +publish independently of slow or failed library shelves; saved metadata still +paints immediately, but a saved hero offers Details until watch data revalidates. +Home and title Play actions resolve the current Jellyfin item and its current +checkpoint at the click, preserving millisecond positions. Series actions use +live NextUp with resumable episodes enabled; an explicitly opened episode stays +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 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 `wolfskin-design/src/debug/assets/wolfskin-home/`; release variants exclude them. 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 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 `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 watched target even while another season is selected. Nested person/title visits 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 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 diff --git a/wolfskin-design/discover-tokens.json b/wolfskin-design/discover-tokens.json index 83266ff5..bf45e265 100644 --- a/wolfskin-design/discover-tokens.json +++ b/wolfskin-design/discover-tokens.json @@ -1,5 +1,6 @@ { "discover": { + "shelfHeadingBand": {"dp": 52, "px": 104, "source": "feedback-2026-11-06: heading-to-card gap 60 px on every Discover lane"}, "sheetPaddingX": { "$type": "dimension", "px": 64, diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverArtwork.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverArtwork.kt index 92e63ad9..77e26655 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverArtwork.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverArtwork.kt @@ -50,8 +50,11 @@ fun DiscoverArtwork(path: String?, modifier: Modifier, poster: Boolean = false, this *= ColorMatrix().apply { setToScale(D.ambientBrightness, D.ambientBrightness, D.ambientBrightness, 1f) } }) else null } val context = LocalContext.current - val request = remember(context, path, poster) { - WolfskinImages.request(context, path, if (poster) 256 else 1280, if (poster) 384 else 720, poster) + val request = remember(context, path, poster, dim) { + 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) else AsyncImage( diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt index bd6c40ef..fcc891ff 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt @@ -86,7 +86,7 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) { add(if (state.hasCatalogue) L.row_chips else WolfskinTokens.Shell.zero) if (!state.hasCatalogue) add(L.row_state) else { 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) } } } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt index 92e8317e..e6ded43c 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt @@ -1,9 +1,6 @@ // SPDX-License-Identifier: GPL-2.0-only 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.border import androidx.compose.foundation.clickable @@ -22,15 +19,13 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.zIndex import androidx.tv.material3.Text 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 column = (state.shell.view.cards[lane.id] ?: 0).coerceIn(0, (count - 1).coerceAtLeast(0)) 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 - var initialized by remember { mutableStateOf(false) } - LaunchedEffect(column, count) { - var target = first - 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)) + val density = LocalDensity.current + LaunchedEffect(column, count, active) { + val target = shelfFirst(column, first, slots, count) state.shell.view.cards["first:${lane.id}"] = target - val offset = cards.layoutInfo.visibleItemsInfo.firstOrNull { it.index == target }?.offset - 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 + cards.scrollShelfTo(target, with(density) { K.posterPitch.toPx() }) } - 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) LazyRow(state = cards, userScrollEnabled = false, contentPadding = PaddingValues(start = L.safeSide, end = WolfskinTokens.Shell.stageWidth - L.safeSide - (K.posterW * slots + K.posterGap * (slots - 1)), top = K.posterGap, bottom = K.posterGap), horizontalArrangement = Arrangement.spacedBy(K.posterGap), - modifier = Modifier.offset(y = 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 -> 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) @@ -97,7 +85,7 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) { Caption( item?.title ?: if (lane.tail == DiscoverTail.SeeAll) "See all ${lane.title}" else if (lane.tail == DiscoverTail.Retry) "Try again" else "Show more", if (item != null) buildDiscoverCaptionFacts(item, state, lane.hidden) else if (lane.tail == DiscoverTail.SeeAll) "${lane.total} titles" else if (lane.tail == DiscoverTail.Retry) "${lane.title} stopped loading" else "The last read only repeated titles", - true, Modifier.offset(L.safeSide + K.posterPitch * (column - first), 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, accentLastFact = item != null && (item.heldBy.isNotEmpty() || state.requests[item.key].orEmpty().isNotEmpty()), ) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeFocus.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeFocus.kt index e91b22cd..1078d8db 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeFocus.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeFocus.kt @@ -69,7 +69,7 @@ class HomeFocus( private fun activate(row: HomeRow) { when (row.kind) { 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.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. */ -internal fun HomeRow.adjustedFirst(column: Int, previous: Int): Int { - var first = previous - 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)) -} +internal fun HomeRow.adjustedFirst(column: Int, previous: Int): Int = + shelfFirst(column, previous, focusSlots, count) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeHeader.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeHeader.kt index eb487f62..8aad42d4 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeHeader.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeHeader.kt @@ -43,9 +43,10 @@ fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List = cards, + val watchReady: Boolean = true, ) { 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 } @@ -84,7 +85,7 @@ data class HomeSnapshot( /** Home emits integration intents, never owns the upstream player or later-slice pages. */ 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 SeeAll(val row: HomeRow) : HomeAction data object Discover : HomeAction diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt index 84766c0e..10090be2 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt @@ -61,25 +61,17 @@ fun HomeScreen( LaunchedEffect(view.row, snapshot.rows) { snapshot.rows.drop(view.row + 1).firstOrNull { it.kind == HomeRowKind.Shelf && !it.skeleton }?.let { WolfskinImages.prefetch(context, it) } } - LaunchedEffect(view) { - 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) { + LaunchedEffect(view, view.row, view.home.resetScroll, heights) { val key = snapshot.rows.getOrNull(view.row)?.id 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 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() }.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 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 ratingsReader = LocalScreenRatings.current diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeShelf.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeShelf.kt index 9b97fe8b..069851f1 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeShelf.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeShelf.kt @@ -2,8 +2,6 @@ package org.justinvisser.wolfskin.design import androidx.compose.foundation.background 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.foundation.layout.* import androidx.compose.foundation.lazy.LazyRow @@ -16,9 +14,8 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.remember import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.snapshotFlow -import kotlinx.coroutines.flow.collectLatest +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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) 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 targetFirst by rememberUpdatedState(first) val density = LocalDensity.current - LaunchedEffect(track) { - track.scrollToItem(track.firstVisibleItemIndex, track.firstVisibleItemScrollOffset) - var positionedFirst = targetFirst - snapshotFlow { targetFirst }.collectLatest { target -> - if (target != positionedFirst) { - positionedFirst = target - val pitch = with(density) { (width + K.posterGap).toPx() } - track.animateScrollBy((target - track.firstVisibleItemIndex) * pitch - track.firstVisibleItemScrollOffset, tween(WolfskinTokens.Motion.base)) - } - } + var positioned by remember(track) { mutableStateOf(false) } + LaunchedEffect(track, first, focused, width, density) { + if (!positioned) track.scrollToItem(first) + else track.scrollShelfTo(first, with(density) { (width + K.posterGap).toPx() }) + positioned = true } 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)) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt index 011a1d70..ea95f24b 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt @@ -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)) } 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 -> 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) } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShelfScroll.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShelfScroll.kt new file mode 100644 index 00000000..fc84ed7f --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShelfScroll.kt @@ -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) +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleArtwork.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleArtwork.kt index 1ea37eb4..cc2696d6 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleArtwork.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleArtwork.kt @@ -98,14 +98,14 @@ fun TitleBackdrop(item: TitleItem, modifier: Modifier = Modifier) { for (index in 0 until 15) matrix.values[index] *= filter[3].toFloat() 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 } .blur(Dp(filter[1].toFloat() / 2)), colorFilter = colorFilter) 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)) } TitleArtwork(item.poster, (L.heroSheetWidth.value * 2).toInt(), (L.heroSheetHeight.value * 2).toInt(), 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(vertical)) @@ -131,13 +131,13 @@ fun TitleTileArtwork(item: TitleItem) { val filter = remember { ColorFilter.colorMatrix(ColorMatrix().apply { 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) - val height = WolfskinTokens.Card.tileH - D.tileSheetInset * 2 + TitleArtwork(item.poster, WolfskinTokens.Image.episodeRequestWidth, WolfskinTokens.Image.episodeRequestHeight, Modifier.fillMaxSize().artOverscan(D.tileAmbientInset).blur(D.tileAmbientBlur), colorFilter = filter) + val height = D.episodeCardHeight - D.tileSheetInset * 2 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)) } 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) - } else TitleArtwork(item.backdrop, WolfskinTokens.Image.tileRequestWidth, WolfskinTokens.Image.tileRequestHeight, Modifier.fillMaxSize()) + } else TitleArtwork(item.backdrop, WolfskinTokens.Image.episodeRequestWidth, WolfskinTokens.Image.episodeRequestHeight, Modifier.fillMaxSize()) } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleHeader.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleHeader.kt index 903bf508..b5d35264 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleHeader.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleHeader.kt @@ -36,6 +36,11 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifier) { if (state.data.status == TitleReadStatus.Loading) { TitleHeaderSkeleton(modifier); return } 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" 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) { @@ -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(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) { 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)) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleModel.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleModel.kt index 103e8818..4630ce75 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleModel.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleModel.kt @@ -91,6 +91,7 @@ data class TitlePageData( val episodesStatus: TitleReadStatus = TitleReadStatus.Ready, val requestsAllowed: Boolean = true, val requestFor: String? = null, + val watchReady: Boolean = true, ) /** 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 Trailer(val url: String) : 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 Request(val item: TitleItem, val forSomeoneElse: Boolean) : TitleAction data class Hide(val item: TitleItem, val hidden: Boolean) : TitleAction diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt index 85f847f5..c683c8e7 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt @@ -2,6 +2,7 @@ package org.justinvisser.wolfskin.design import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -90,6 +91,10 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie val list = shell.view.listState var wheelPending by remember { mutableStateOf(false) } 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) { 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") } } - val rowOffsets = remember(displayedRows, density) { + val rowOffsets = remember(displayedRows, density, headerHeight) { 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 { 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) val index = if (shell.navFocused) 0 else displayedRows.indexOf(state.row).coerceAtLeast(0) 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 else y - with(density) { L.pinnedLine.toPx() } 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") { val active = state.row == id && !shell.navFocused && state.sheet == null 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) "episodes" -> TitleMediaShelf(state, true, active) "cast" -> TitleCast(state, active) @@ -156,16 +165,16 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie } } } 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) { - "header" -> L.row_dhead +private fun titleRowHeight(row: String, headerHeight: Dp): Dp = when (row) { + "header" -> headerHeight "seasons" -> L.row_chips - "episodes" -> L.rowHeadingBand + WolfskinTokens.Card.tileH + L.rowGapAfter + "episodes" -> L.rowHeadingBand + D.episodeCardHeight + L.rowGapAfter "cast" -> D.castHeight else -> L.rowHeadingBand + WolfskinTokens.Card.posterH + WolfskinTokens.Card.posterCaptionBand + L.rowGapAfter } @@ -173,7 +182,12 @@ private fun titleRowHeight(row: String): Dp = when (row) { @Composable private fun TitleSeasons(state: TitleState, active: Boolean) { 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 -> val number = state.data.seasonNumbers[index] val focused = active && state.column == index diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePrefetch.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePrefetch.kt index 641fead0..d4a956fc 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePrefetch.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePrefetch.kt @@ -22,7 +22,7 @@ internal fun TitleRowPrefetch(data: TitlePageData, row: String?) { queue(data.cast[index].portrait, WolfskinTokens.Image.castRequestWidth, WolfskinTokens.Image.castRequestHeight) "episodes" -> for (index in 0 until minOf(data.episodes.size, WolfskinTokens.Card.tileSlots)) { 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)) queue(data.related[index].poster, WolfskinTokens.Image.posterRequestWidth, WolfskinTokens.Image.posterRequestHeight, true) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt index 15a74880..6d7752ae 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt @@ -3,8 +3,6 @@ package org.justinvisser.wolfskin.design import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut 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.foundation.layout.fillMaxHeight 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.zIndex import androidx.tv.material3.Text +import kotlin.math.roundToInt import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D @@ -50,7 +49,9 @@ fun TitleCast(state: TitleState, active: Boolean) { val track = rememberLazyListState() val focusOwner = LocalTitleFocusOwner.current 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)) { 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), @@ -81,11 +82,13 @@ fun TitleMediaShelf(state: TitleState, episodes: Boolean, active: Boolean) { val titles = if (episodes) state.data.episodes else state.data.related val id = if (episodes) "episodes" else "related" 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 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() } - 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)) { 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) @@ -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] } } LibraryPoster(item, focused, focused && ratingsVisible, catalogue = catalogue, 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) .clip(RoundedCornerShape(WolfskinTokens.Radius.card)).background(C.surface2).clickable { focusOwner?.invoke(); onClick() }) { 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)) { Text(item.seriesName ?: item.name, color = C.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis) Text("${item.episodeLabel} · ${item.name}", color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis) + if (focused && item.overview.isNotBlank()) Text(item.overview, color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 2, overflow = TextOverflow.Ellipsis) } val progress = item.progress 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. */ -private suspend fun LazyListState.keepColumnInWindow(column: Int, slots: Int, pitch: Float) { - val first = firstVisibleItemIndex - val next = when { - column < first -> column - column >= first + slots - 1 -> (column - slots + 2).coerceAtLeast(0) - else -> first - } - if (next != first) animateScrollBy((next - first) * pitch - firstVisibleItemScrollOffset, titleTrackScrollSpec) +/** 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, count: Int) { + val previous = firstVisibleItemIndex + (firstVisibleItemScrollOffset / pitch).roundToInt() + scrollShelfTo(shelfFirst(column, previous, slots, count), pitch) } - -private val titleTrackScrollSpec = tween(WolfskinTokens.Motion.base, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleSlotArtwork.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleSlotArtwork.kt index 242e0e6b..154c44a3 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleSlotArtwork.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleSlotArtwork.kt @@ -48,12 +48,12 @@ fun TitleArtwork(title: String, type: String, poster: String?, width: Dp, height .requiredSize(width + inset * 2, height + inset * 2) .blur(if (hero) H.heroBlur else H.cardBlur) .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 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) 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 HomeAmbient(Modifier.fillMaxSize()) } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleState.kt index 2e5da10c..d8a6d560 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleState.kt @@ -31,6 +31,8 @@ class TitleState( var watched by mutableStateOf(initial.watchTarget.watched) var toast by mutableStateOf(null) private var armedCard: String? = null + private var seasonChosen = false + val requestedSeason: Int? get() = season.takeIf { seasonChosen } private var okHeld = false 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() @@ -56,7 +58,7 @@ class TitleState( val pendingAudio = audioIndex != data.selectedAudio val pendingSubtitle = subtitleIndex != data.selectedSubtitle 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 (!pendingVersion) versionId = value.selectedVersion if (!pendingAudio) audioIndex = value.selectedAudio @@ -152,7 +154,8 @@ class TitleState( val next = rows[index] val x = centers[row]?.get(column) 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) { @@ -168,15 +171,15 @@ class TitleState( when (row) { "cast" -> data.cast.getOrNull(column)?.let { onAction(TitleAction.Person(it)) } "related" -> data.related.getOrNull(column)?.let { onAction(TitleAction.OpenTitle(it)) } - "episodes" -> data.episodes.getOrNull(column)?.let { onAction(TitleAction.OpenTitle(it)) } - "seasons" -> { season = data.seasonNumbers[column]; onAction(TitleAction.Season(season)) } + "episodes" -> data.episodes.getOrNull(column)?.let { onAction(TitleAction.OpenTitle(it, episodeSelection = true)) } + "seasons" -> { seasonChosen = true; season = data.seasonNumbers[column]; onAction(TitleAction.Season(season)) } "header" -> options.getOrNull(column)?.let { activateOption(it.id) } } } private fun activateOption(id: String) { 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 } "tracks" -> { sheetIndex = 0; sheet = TitleSheet.Tracks } "more" -> { sheetIndex = 0; sheet = TitleSheet.MediaInfo } @@ -260,7 +263,8 @@ fun titleOptions(state: TitleState): List = buildList { return@buildList } 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" 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")) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WhosWatching.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WhosWatching.kt index 48d6c5e6..224469bc 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WhosWatching.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WhosWatching.kt @@ -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 - total) / 2 + (L.pickerAvatar + L.pickerGap) * state.people.lastIndex + (L.pickerAvatar - L.gear) / 2 + val left = (S.stageWidth - L.gear) / 2 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) { Glyph("gear", if (state.gearFocused) C.fg else C.dim.copy(alpha = C.dim.alpha * S.gearOpacity), Modifier.size(L.buttonIcon)) if (state.gearFocused) { diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinImages.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinImages.kt index 4e0822bf..e9b63760 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinImages.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinImages.kt @@ -1,6 +1,9 @@ package org.justinvisser.wolfskin.design import android.content.Context +import android.graphics.ImageDecoder +import android.os.Build +import android.net.Uri import coil3.ComponentRegistry import coil3.ImageLoader import coil3.disk.DiskCache @@ -10,6 +13,7 @@ import coil3.request.crossfade import coil3.memory.MemoryCache import coil3.request.ImageRequest import coil3.request.allowRgb565 +import coil3.size.Scale import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit @@ -21,6 +25,8 @@ object WolfskinImages { private var networkConfiguration: (ComponentRegistry.Builder.() -> Unit)? = null private const val MEBIBYTE = 1024L * 1024L private const val IMAGE_READS = 4 + private const val CARD_QUALITY = "80" + private const val HERO_QUALITY = "96" private val readPermits = Semaphore(IMAGE_READS) /** The app supplies authenticated networking without exposing its server/session to design code. */ @@ -61,7 +67,7 @@ object WolfskinImages { } 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) { 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)) } } + + 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() + } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt index 4a9ec01c..6b77b2ef 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt @@ -220,12 +220,16 @@ object WolfskinTokens { object Image { const val posterRequestWidth = 256 const val posterRequestHeight = 384 - const val tileRequestWidth = 416 - const val tileRequestHeight = 234 - const val wideRequestWidth = 560 - const val wideRequestHeight = 315 - const val heroRequestWidth = 1280 - const val heroRequestHeight = 720 + const val tileRequestWidth = 400 + const val tileRequestHeight = 225 + const val wideRequestWidth = 544 + const val wideRequestHeight = 306 + const val heroRequestWidth = 1920 + 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 castRequestHeight = 200 const val memoryCacheMB = 64.0f @@ -416,6 +420,8 @@ object WolfskinTokens { const val sheetOpacity = 0.5f } object Title { + val episodeCardHeight = 140.0.dp + val seriesHeaderGap = 20.0.dp val lineGap = 11.0.dp val lineLift = -2.0.dp val overviewGap = 17.0.dp @@ -609,6 +615,7 @@ object WolfskinTokens { val posterProgressBottom = 6.0.dp } object Discover { + val shelfHeadingBand = 52.0.dp val sheetPaddingX = 32.0.dp val sheetPaddingY = 40.0.dp val sheetGap = 9.0.dp diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeKeysTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeKeysTest.kt index 01c953b8..a23faa73 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeKeysTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeKeysTest.kt @@ -36,8 +36,8 @@ class HomeKeysTest { for (key in 0 until keys.length()) press(keys.getString(key)) assertEquals(path.getString("goal"), path.getInt("presses"), keys.length()) when (index) { - 0 -> { assertEquals("mindel", shell.person.key); assertTrue(action is HomeAction.Play); assertTrue((action as HomeAction.Play).resume) } - 1 -> { assertTrue(action is HomeAction.Play); assertFalse((action as HomeAction.Play).resume); assertEquals("Yu Yu Hakusho", (action as HomeAction.Play).title.seriesTitle) } + 0 -> { assertEquals("mindel", shell.person.key); assertTrue(action is HomeAction.Play) } + 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) 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) } @@ -170,7 +170,6 @@ class HomeKeysTest { assertEquals(0, shell.view.cards["hero"]) press("OK") val play = action as HomeAction.Play - assertFalse(play.resume) assertEquals("Yu Yu Hakusho", play.title.seriesTitle) } diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeViewportTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeViewportTest.kt new file mode 100644 index 00000000..fe86448f --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeViewportTest.kt @@ -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() + + @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(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) { + 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) + } +} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/WolfskinImagesTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/WolfskinImagesTest.kt new file mode 100644 index 00000000..dfefe549 --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/WolfskinImagesTest.kt @@ -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())) + } + } +} diff --git a/wolfskin-design/title-tokens.json b/wolfskin-design/title-tokens.json index 5138e020..156054d0 100644 --- a/wolfskin-design/title-tokens.json +++ b/wolfskin-design/title-tokens.json @@ -1,5 +1,7 @@ { "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"}, "lineLift": {"dp": -2, "px": -4, "source": "index.html:214"}, "overviewGap": {"dp": 17, "px": 34, "source": "index.html:216"}, diff --git a/wolfskin-design/tokens.css b/wolfskin-design/tokens.css index 9c7f17f1..423a5e4c 100644 --- a/wolfskin-design/tokens.css +++ b/wolfskin-design/tokens.css @@ -260,6 +260,8 @@ --lift-heroY: 30.0px; --lift-sheetBlur: 80.0px; --lift-sheetX: -30.0px; + --title-episodeCardHeight: 280.0px; + --title-seriesHeaderGap: 40.0px; --title-lineGap: 22.0px; --title-lineLift: -4.0px; --title-overviewGap: 34.0px; @@ -436,6 +438,7 @@ --home-savedIcon: 28.0px; --home-savedRadius: 28.0px; --home-posterProgressBottom: 12.0px; + --discover-shelfHeadingBand: 104.0px; --discover-sheetPaddingX: 64.0px; --discover-sheetPaddingY: 80.0px; --discover-sheetGap: 18.0px; diff --git a/wolfskin-design/tokens.json b/wolfskin-design/tokens.json index f8121dc4..cc1a845b 100644 --- a/wolfskin-design/tokens.json +++ b/wolfskin-design/tokens.json @@ -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": { "density": 2.0, "stagePx": [ @@ -12,7 +12,8 @@ ], "source": "data/tv-v2/focus-2026-11-06/index.html", "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": { "bg": { @@ -1491,8 +1492,8 @@ }, "tileRequest": { "px": [ - 416, - 234 + 400, + 225 ], "decode": "ARGB_8888", "source": "index.html:549", @@ -1500,8 +1501,8 @@ }, "wideRequest": { "px": [ - 560, - 315 + 544, + 306 ], "decode": "ARGB_8888", "source": "index.html:401", @@ -1509,13 +1510,23 @@ }, "heroRequest": { "px": [ - 1280, - 720 + 1920, + 1080 ], "decode": "ARGB_8888", - "bytes": 3686400, - "source": "index.html:549", - "usedBy": "Home hero, Discover hero, title backdrop: the 1280x720 source, never upscaled; released when scrolled past" + "bytes": 8294400, + "source": "feedback-2026-11-06: panel-resolution artwork", + "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": { "px": [ diff --git a/wolfskin-design/tokens.sha256 b/wolfskin-design/tokens.sha256 index b3fabec1..405b0abe 100644 --- a/wolfskin-design/tokens.sha256 +++ b/wolfskin-design/tokens.sha256 @@ -1,8 +1,8 @@ -1f22175c1ce3987c4efa14d573601641a61e1a915e2c464c943b6c400356adb4 discover-tokens.json +b3f096b692c395534177cc82ac68f5b801870bc7e28f0dd5cd7c17e2103920a3 discover-tokens.json 85109c394255f6120a621fbefe8e58df82832974ccd6a6a975a61567f641ea2a generate_tokens.py 25985087986a46f00e2819917d0421ff2b437ec21c54f6ce6412b84c0eb05302 home-tokens.json be3af0ddcb0d67abef0805d19d7a33cdb55428bcace656b50f0a38935f212028 shell-tokens.json -c5ca037f08a0ab36139bf3805f1e0305e4c8e31e0d198cf056c791476950b96b src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt -8ae28b149e38a00952c07a6c27bb5df667101acf09b7b571b658774f453b2063 title-tokens.json -b482e5c532b50cffae9923b86efd03d40f1a56300e1c3c6d969b965f41ea1670 tokens.css -3b8b2112917dbd9fb16030cc1f3de2c35b5b6775136d16600b55ffd83936da69 tokens.json +580ed1710468ccbd97082afaa445e96ae71510ebace711cc735ea389ecb9249d src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +e0a2f5df2f249bce3f6e13f4a0320c7b18b85777eaf04a27a428d6683a6aad28 title-tokens.json +36d5b2646dff2e4055d2683e4c284111a6886c311e9c924b73061fa785bc863d tokens.css +226f4bd07c0b105f8640beff71fe2c0c95d0f8862e624cea511384a4591b44ad tokens.json