Compare commits

..

83 commits

Author SHA1 Message Date
01ab781d23 Merge pull request 'Limit auto-off warm continuation to trailer handoffs' (#83) from fm/tv-trailer-narrow into fm/tv-round2-candidate3
All checks were successful
gate/round2-candidate Full suite 1282/1282 and Opt 1.1.2/71 checks on this exact head (data/tv-r2-gate4/result.json)
review/round2-candidate Combined round review at 3a315eb8 success, plus delta safety check of the trailer-only narrowing (PR 83) PASS with no blockers
2026-11-08 13:30:42 +01:00
4a6d318f8f Limit auto-off warm continuation to trailer handoffs
All checks were successful
review/delta-claude Delta check: trailer-only narrowing correct, no safeguard weakened, 0 blockers
2026-11-08 13:08:05 +01:00
root
3a315eb809 Candidate 3: merge new polish-lane-e head
All checks were successful
gate/round2-candidate full suite on da003d9e: design 295/295, app 945/946; the 1 failure (UpdatesKeysTest, lane E) fixed test-only in 3a315eb8 and verified by targeted run 12/12; Opt vc70 signer ok
review/round2-candidate No blocker; 1 decision for Justin (warm return), 9 minor findings
2026-11-08 08:05:07 +01:00
fa08235ac4 Wait for Settings scroll idle before asserting the retained row 2026-11-08 08:02:37 +01:00
45b49a960f Merge remote-tracking branch 'origin/fm/tv-round2' into fm/polish-lane-e 2026-11-08 07:54:51 +01:00
root
da003d9ea4 Give the app test JVM a 2g heap 2026-11-08 07:17:07 +01:00
root
3da8a31c5b Fix test constructor calls broken by merging the polish lanes with the trailer-return fix
Test-only; product code is unchanged.
NavigationCutTest passes the new liveSession to MainContent and trailerReturn to the shell view model; TitleEntryBackTest passes the session to the Home view model; TitlePolishTest passes trailerReturn to the title view model.
2026-11-08 07:01:50 +01:00
root
eb3c65af19 Candidate 3: merge tv-bug-trailer-return 2026-11-08 06:50:39 +01:00
root
fad0ca672c Candidate 3: merge tv-bug-season-reel 2026-11-08 06:50:17 +01:00
root
aa470b96b6 Candidate 3: merge polish-lane-c 2026-11-08 06:50:17 +01:00
root
4785fd8d5e Candidate 3: merge polish-lane-d 2026-11-08 06:50:17 +01:00
root
e378f34f49 Candidate 3: merge polish-lane-b 2026-11-08 06:50:17 +01:00
root
a069a72d9f Candidate 3: merge polish-lane-a 2026-11-08 06:50:16 +01:00
root
f29ecaace0 Candidate 3: merge polish-lane-e 2026-11-08 06:50:16 +01:00
1bdae30e11 Restore bounded trailer tasks after process death 2026-11-08 06:46:24 +01:00
bfa9fbc941 Keep playback black through native scene handoff 2026-11-08 05:33:54 +01:00
156c140731 Measure native cuts independently of retained semantics 2026-11-08 04:31:20 +01:00
7cd38026f1 Establish painted navigation and player-return preconditions 2026-11-08 04:03:44 +01:00
f05b0de156 Polish title sheets, playback handoff and season motion 2026-11-08 04:00:17 +01:00
b4742bed6d Observe frame-zero cuts and keep picker masks opaque 2026-11-08 03:36:19 +01:00
612ae79f0c Polish picker switching and Home playback handoff 2026-11-08 03:16:09 +01:00
d418a89a25 Keep season episode reels in chronological order 2026-11-08 03:15:03 +01:00
b6665d016b Keep live title sessions when returning from trailer apps 2026-11-08 03:10:13 +01:00
9a0ce25a27 Polish Discover sheets and Library row motion 2026-11-08 03:03:11 +01:00
6f54bae38e Glide card focus and carry key-driven scroll momentum 2026-11-08 02:59:51 +01:00
2572da00fd Polish Requests transitions and shell hosts 2026-11-08 01:56:06 +01:00
12a81ca8e1 Merge pull request 'Apply the subtitle delay on ExoPlayer' (#74) from fm/sub-offset-core into fm/tv-round2 2026-11-08 01:45:20 +01:00
0a7c059f24 Add an end-to-end subtitle delay playback test
All checks were successful
review/code-claude Code APPROVE: zero path identical, exact shift, on-time end
review/design-claude Design PASS: existing delay row only, no new UI
2026-11-08 01:40:09 +01:00
7c03a3fe0d Apply the subtitle delay on ExoPlayer and show the delay control 2026-11-08 01:40:09 +01:00
0c9bffc3ab Add a subtitle delay renderer wrapper for ExoPlayer 2026-11-08 01:40:09 +01:00
709ca29170 Merge pull request 'Reset subtitle delay when the new track has none saved' (#73) from fm/sub-offset-reset into fm/tv-round2 2026-11-08 01:37:40 +01:00
8da699ca8b Reset subtitle delay when the new track has none saved
All checks were successful
review/code-claude Code APPROVE: reset exactly per plan, persistence unchanged
review/design-claude Design PASS: no UI change
2026-11-08 01:33:55 +01:00
328916c5ac Merge pull request 'Remove the competing Discover title preview' (#72) from fm/tv-details-flash-2 into fm/tv-round2 2026-11-08 01:01:37 +01:00
c80afb98a2 Remove the competing Discover title preview
All checks were successful
review/code-claude Code APPROVE: preview removed, shared title owns loading
review/design-claude Design PASS: outgoing hero pixel-identical, no approved pixels change
2026-11-08 00:47:31 +01:00
74b737fb52 Add the shared polish tokens and motion helpers 2026-11-08 00:12:17 +01:00
5f5b5ffd54 Merge pull request 'Play held-title theme music while the title page is resumed' (#71) from fm/theme-music-title into fm/tv-round2 2026-11-07 23:47:29 +01:00
2f3cdf2339 Play held-title themes only while their page is resumed
All checks were successful
review/code-claude Code APPROVE: held-only, stops on leave/playback/background/person
review/design-claude Design PASS: no visual change
2026-11-07 23:42:12 +01:00
2c5e39261c Merge pull request 'Restore household colours on the cold-start profile picker' (#70) from fm/tv-picker-colors into fm/tv-round2 2026-11-07 23:41:28 +01:00
27b7554fc1 Restore household colours on the cold-start profile picker
All checks were successful
review/code-claude Code APPROVE: key change only, auth ids untouched
review/design-claude Design PASS: Justin blue, Mindel orange, Shared green
2026-11-07 23:38:37 +01:00
4af1898fd2 Merge pull request 'Remove the unfocused watched control ring' (#69) from fm/tv-watched-ring into fm/tv-round2 2026-11-07 23:28:01 +01:00
d73796742c Remove the unfocused watched control ring
All checks were successful
review/code-claude Code APPROVE: one-line ring removal, only Watched uses on
review/design-claude Design PASS: on state readable by glyph, focus ring unchanged
2026-11-07 23:23:39 +01:00
49d8ffc892 Merge pull request 'Round 2 release candidate 2: round branch merged with main (updater) and Back fix' (#68) from fm/tv-round2-rc2 into fm/tv-round2 2026-11-07 23:20:12 +01:00
root
408aff3db7 Fix two tests that predate the merge of the round and main
All checks were successful
gate/round2-candidate design 253/253 + app 830/832 on 2eeacdc5, the 2 failing classes pass on this commit (test-only delta); Opt build versionCode 69 signer ok
Test-only; product code is unchanged.
UpdatesKeysTest presses Key.Escape for Back because the Back fix routes native Back through the Activity dispatcher and keeps only Escape as the shell key.
UpstreamContentFocusTest stubs the relaxed view model's updates with the fake controller, as the other tests do, so the Updates row gets an installed version.
2026-11-07 23:05:41 +01:00
root
2eeacdc531 Pass the update controller in the root exit test after merging main 2026-11-07 22:39:20 +01:00
1dee89975e Merge pull request 'Fix system Back ownership for titles and Home' (#67) from fm/tv-back-title into fm/tv-round2 2026-11-07 22:36:19 +01:00
root
ed103bcb96 Round 2 candidate 2: merge origin/main 2026-11-07 22:34:09 +01:00
root
d739a201ee Round 2 candidate 2: merge origin/fm/tv-back-title 2026-11-07 22:33:36 +01:00
a268cdd7b8 Route system Back through the current navigation owner
All checks were successful
review/code-claude Code APPROVE: single Back owner, tests fail before and pass after
review/design-claude Design PASS: no visual change
2026-11-07 22:30:20 +01:00
985ebc53a7 Merge pull request 'Keep loaded title refreshes stable and guard request identities' (#66) from fm/tv-discover-details-flash into fm/tv-round2 2026-11-07 22:22:58 +01:00
5f759b8fef Keep loaded title refreshes stable and guard request identities
All checks were successful
review/code-claude Code APPROVE: no stale data, request guard recovers via toast
review/design-claude Design PASS: no look change beyond flash removal
2026-11-07 22:16:30 +01:00
b51dced5fe Merge pull request 'Round 2 release candidate: Home, Discover, trailer, crash fix and stock screens' (#64) from fm/tv-round2-rc into fm/tv-round2 2026-11-07 21:14:14 +01:00
root
5fb37886bb final merge dup-lazy-key
All checks were successful
gate/round2-candidate full make test 1016/1016 (7m43s, no retries), Opt build versionCode 68 signer ok
2026-11-07 20:55:19 +01:00
64432b0032 Compute lazy list keys from the list the lazy block renders
All checks were successful
review/code-claude Code APPROVE: no blocking finding
review/design-claude Design PASS: no visible change, no blocker
2026-11-07 20:54:53 +01:00
root
502d43f271 prep merge tv-skip-stock-setup 2026-11-07 20:50:32 +01:00
root
8b6edf5a96 prep merge tv-trailer-intent 2026-11-07 20:50:32 +01:00
root
abebd52850 prep merge discover 2026-11-07 20:50:32 +01:00
a5b5e92017 Route Wolfskin setup through the household profile picker
All checks were successful
review/code-claude Opus review PASS: no blockers, 10 non-blocking notes
review/safety-opus Opus review PASS: no blockers, 10 non-blocking notes
2026-11-07 20:46:46 +01:00
6f85b9c611 Make repeated ids safe as lazy list keys across the Wolfskin screens 2026-11-07 20:31:42 +01:00
8be769b67c Launch trailers with the vnd.youtube authority form SmartTube can parse
All checks were successful
review/code-claude Code APPROVE: no blocking finding
review/design-claude Design PASS: no visible change, no blocker
2026-11-07 19:48:04 +01:00
f375d88030 Reserve Home hero primary action space
All checks were successful
review/code-claude Code APPROVE: delta f375d880 fine
review/design-claude Design PASS: gear restored, hero buttons stationary
2026-11-07 19:46:17 +01:00
7c67596cb6 Restore approved picker Settings centring 2026-11-07 19:38:53 +01:00
a27e922675 Cover outgoing title and person Back ownership
Some checks failed
review/code-claude Code APPROVE: Home delta fine, no blocker
review/design-claude Design BLOCK: picker gear moved without approval (B1)
2026-11-07 19:19:45 +01:00
c5dbe91076 Start new Home rows leftmost and gate outgoing Back handlers 2026-11-07 18:52:30 +01:00
79f64f6e18 Restore sequential hero copy and first-appearance entry
All checks were successful
review/code-claude Code APPROVE: sequential copy hand-off, no blocker
review/design-claude Design PASS: copy hand-off matches approved frames/tokens
2026-11-07 18:44:00 +01:00
5811ab0555 Synchronize saved-picker test with Android main queue
All checks were successful
review/code-claude No blocking findings; 8 non-blocking notes (see PR comment)
review/design-claude Design review PASS: no blocking finding; 8 non-blocking notes
2026-11-07 18:18:50 +01:00
33cca64eba Merge remote-tracking branch 'origin/fm/tv-round2' into fm/tv-r2-home 2026-11-07 18:02:26 +01:00
9cfe9362ee Fix picker access and finish Wolfskin on root Back 2026-11-07 18:02:17 +01:00
7bfa431350 Retain destination focus in the initial title loading regression
Some checks failed
review/code-claude Code review: approve, no blocking finding (9 non-blocking notes)
review/design-claude Blocked: hero copy counter-fade overlaps text; approved sequential hand-off differs (B1)
2026-11-07 17:48:54 +01:00
aa31199b4f Merge title follow-up integration before final gates 2026-11-07 17:10:07 +01:00
2b142af8d1 Merge pull request 'Show episode ratings and smoothly collapse title readiness' (#57) from fm/tv-r2-title-2 into fm/tv-round2 2026-11-07 17:07:19 +01:00
f9bd630ea7 Keep title destination focus and preserve ready catalogue refreshes 2026-11-07 16:56:49 +01:00
e6ddf1d7e5 Show episode ratings and animate title readiness without empty space
All checks were successful
review/code-claude Reviewed e6ddf1d7: his board picks implemented, no blocking finding
review/design-claude Reviewed e6ddf1d7: his board picks implemented, no blocking finding
2026-11-07 16:41:07 +01:00
98dd54e03b Merge the TV round two integration branch 2026-11-07 16:22:09 +01:00
9988a193de Hold the full painted backdrop until incoming hero art is ready 2026-11-07 16:22:09 +01:00
174e1ae5c9 Merge current TV integration into title follow-up 2026-11-07 16:20:47 +01:00
febcded034 Merge round-two integration updates into Home work 2026-11-07 16:12:54 +01:00
ae613216d0 Merge main into the round-two integration branch 2026-11-07 16:01:56 +01:00
60b0c307aa Keep refresh capture artwork on the local fixture server 2026-11-07 15:59:00 +01:00
12e908389f Smooth Discover hero handoffs and scope lane focus to each visit 2026-11-07 15:56:19 +01:00
5046727b2a Merge pull request 'Refine TV title stacking, episode cards and action menus' (#56) from fm/tv-r2-title into fm/tv-round2 2026-11-07 15:53:33 +01:00
a159c835c5 Preserve episode height and fix title overlay and poster collisions
All checks were successful
review/code-claude Blockers fixed on a159c835, delta reviewed
review/design-claude Blockers fixed on a159c835, delta reviewed
2026-11-07 15:46:56 +01:00
12c201dc55 Fix Home navigation ownership and merge watch lanes 2026-11-07 15:23:45 +01:00
6d7513bd0e Refine title episode cards, section stacking and action menus
Some checks failed
review/code-claude Code review: pass, no blocking findings (9 non-blocking notes)
review/design-claude Blocked: card height 126 vs 140 dp, Menu to Media info flash, poster/cast overlap
2026-11-07 15:11:02 +01:00
176 changed files with 11742 additions and 1201 deletions

View file

@ -479,6 +479,8 @@ dependencies {
testImplementation("com.squareup.okhttp3:mockwebserver3") testImplementation("com.squareup.okhttp3:mockwebserver3")
testImplementation(libs.androidx.core.testing) testImplementation(libs.androidx.core.testing)
testImplementation(libs.robolectric) testImplementation(libs.robolectric)
testImplementation(libs.androidx.media3.test.utils)
testImplementation(libs.androidx.media3.test.utils.robolectric)
testImplementation(libs.hilt.android.testing) testImplementation(libs.hilt.android.testing)
testImplementation(libs.androidx.compose.ui.test.junit4) testImplementation(libs.androidx.compose.ui.test.junit4)
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.77.0") testImplementation("io.github.takahirom.roborazzi:roborazzi:1.77.0")
@ -491,6 +493,10 @@ dependencies {
tasks.withType<Test>().configureEach { tasks.withType<Test>().configureEach {
dependsOn(":wolfskin-design:testDebugUnitTest") dependsOn(":wolfskin-design:testDebugUnitTest")
// Android 16 shared-memory setup needs Robolectric's JDK FileDescriptor bridge.
jvmArgs("--add-exports=java.base/jdk.internal.access=ALL-UNNAMED")
// The full suite keeps every Robolectric capture in one worker; the 512 MB default runs out near the end.
maxHeapSize = "2g"
systemProperty("robolectric.dependency.dir", project(":wolfskin-design").layout.buildDirectory.dir("robolectric-sdk").get().asFile.absolutePath) systemProperty("robolectric.dependency.dir", project(":wolfskin-design").layout.buildDirectory.dir("robolectric-sdk").get().asFile.absolutePath)
systemProperty("robolectric.offline", "true") systemProperty("robolectric.offline", "true")
systemProperty("roborazzi.test.record", "true") systemProperty("roborazzi.test.record", "true")

View file

@ -53,9 +53,11 @@ class InstrumentedBasicUiTests {
MainContent( MainContent(
backStack = mutableListOf(SetupDestination.ServerList), backStack = mutableListOf(SetupDestination.ServerList),
navigationManager = navigationManager, navigationManager = navigationManager,
appPreferences = mockk(relaxed = true), userPreferences = mockk(relaxed = true),
backdropService = mockk(relaxed = true), backdropService = mockk(relaxed = true),
screensaverService = screensaverService, screensaverService = screensaverService,
liveSession = MutableStateFlow(null),
trailerReturn = mockk(relaxed = true),
modifier = Modifier, modifier = Modifier,
) )
} }

View file

@ -1,6 +1,5 @@
package com.github.damontecres.wholphin package com.github.damontecres.wholphin
import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.res.Configuration import android.content.res.Configuration
import android.os.Bundle import android.os.Bundle
@ -9,7 +8,6 @@ import android.view.WindowManager
import android.widget.Toast import android.widget.Toast
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.annotation.VisibleForTesting
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@ -24,23 +22,16 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.lifecycle.ViewModel
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.viewModelScope
import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavBackStack
import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface import androidx.tv.material3.Surface
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.RestoredSession
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.services.AppUpgradeHandler
import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.DatePlayedInvalidationService import com.github.damontecres.wholphin.services.DatePlayedInvalidationService
import com.github.damontecres.wholphin.services.DeviceProfileService
import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.services.IntentResult
import com.github.damontecres.wholphin.services.IntentService import com.github.damontecres.wholphin.services.IntentService
import com.github.damontecres.wholphin.services.LatestNextUpSchedulerService import com.github.damontecres.wholphin.services.LatestNextUpSchedulerService
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
@ -65,7 +56,6 @@ import com.github.damontecres.wholphin.ui.components.LocalSeerrRatingsService
import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.playback.PlayExternalViewModel import com.github.damontecres.wholphin.ui.playback.PlayExternalViewModel
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
import com.github.damontecres.wholphin.util.DebugLogTree import com.github.damontecres.wholphin.util.DebugLogTree
@ -73,20 +63,17 @@ import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.WholphinDispatchers import com.github.damontecres.wholphin.util.WholphinDispatchers
import com.github.damontecres.wholphin.util.requestSerializersModule import com.github.damontecres.wholphin.util.requestSerializersModule
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import timber.log.Timber import timber.log.Timber
import org.justinvisser.wolfskin.tv.TrailerReturnState
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@ -155,6 +142,9 @@ class MainActivity : AppCompatActivity() {
@Inject @Inject
lateinit var intentService: IntentService lateinit var intentService: IntentService
@Inject
lateinit var trailerReturn: TrailerReturnState
private var signInAuto = true private var signInAuto = true
private var playerBackend: PlayerBackend? = null private var playerBackend: PlayerBackend? = null
@ -168,6 +158,7 @@ class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
instance = this instance = this
if (savedInstanceState == null) trailerReturn.clear()
Timber.i("MainActivity.onCreate: savedInstanceState is null=${savedInstanceState == null}") Timber.i("MainActivity.onCreate: savedInstanceState is null=${savedInstanceState == null}")
lifecycle.addObserver(playbackLifecycleObserver) lifecycle.addObserver(playbackLifecycleObserver)
@ -232,7 +223,7 @@ class MainActivity : AppCompatActivity() {
playerBackend = prefs.playbackPreferences.playerBackend playerBackend = prefs.playbackPreferences.playerBackend
} }
viewModel.appStart(intent) viewModel.appStart(intent, resuming = savedInstanceState != null, savedState = savedInstanceState)
setContent { setContent {
MaterialTheme { MaterialTheme {
Surface(Modifier.fillMaxSize()) { Surface(Modifier.fillMaxSize()) {
@ -282,6 +273,8 @@ class MainActivity : AppCompatActivity() {
userPreferences = userPreferences, userPreferences = userPreferences,
backdropService = backdropService, backdropService = backdropService,
screensaverService = screensaverService, screensaverService = screensaverService,
liveSession = viewModel.serverRepository.current,
trailerReturn = trailerReturn,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
} }
@ -308,6 +301,7 @@ class MainActivity : AppCompatActivity() {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
Timber.d("onResume") Timber.d("onResume")
trailerReturn.finishHandoff()
viewModel.appResume() viewModel.appResume()
lifecycleScope.launchDefault { lifecycleScope.launchDefault {
screensaverService.pulse() screensaverService.pulse()
@ -317,7 +311,7 @@ class MainActivity : AppCompatActivity() {
override fun onRestart() { override fun onRestart() {
super.onRestart() super.onRestart()
Timber.d("onRestart") Timber.d("onRestart")
viewModel.appStart(null) viewModel.appStart(null, resuming = true)
if (!playExternalViewModel.launched.value) { if (!playExternalViewModel.launched.value) {
// If restarting during playback that is not external, go back a page // If restarting during playback that is not external, go back a page
val lastDest = navigationManager.backStack.lastOrNull() val lastDest = navigationManager.backStack.lastOrNull()
@ -373,6 +367,7 @@ class MainActivity : AppCompatActivity() {
Timber.w(ex, "Could not persist back stack; skipping") Timber.w(ex, "Could not persist back stack; skipping")
} }
outState.putBoolean(KEY_EXTERNAL_PLAYER, playerBackend == PlayerBackend.EXTERNAL_PLAYER) outState.putBoolean(KEY_EXTERNAL_PLAYER, playerBackend == PlayerBackend.EXTERNAL_PLAYER)
trailerReturn.save(outState, viewModel.serverRepository.current.value, navigationManager.backStack.lastOrNull())
} }
override fun onRestoreInstanceState(savedInstanceState: Bundle) { override fun onRestoreInstanceState(savedInstanceState: Bundle) {
@ -423,179 +418,6 @@ class MainActivity : AppCompatActivity() {
} }
} }
@HiltViewModel
class MainActivityViewModel
@Inject
constructor(
@param:ApplicationContext private val context: Context,
private val preferences: DataStore<AppPreferences>,
val serverRepository: ServerRepository,
private val setupNavigationManager: SetupNavigationManager,
private val navigationManager: NavigationManager,
private val deviceProfileService: DeviceProfileService,
private val backdropService: BackdropService,
private val appUpgradeHandler: AppUpgradeHandler,
private val intentService: IntentService,
) : ViewModel() {
private val mutex = Mutex()
fun appStart(intent: Intent?) {
viewModelScope.launchDefault {
mutex.withLock {
try {
handleAppUpgrade()
} catch (ex: Exception) {
Timber.e(ex, "Error during app upgrade!")
showToast(
context,
"Error during app upgrade: ${ex.localizedMessage}",
)
}
try {
val result = handleIntent(intent)
if (result) {
Timber.i("appStart was handled by intent")
return@withLock
}
} catch (ex: Exception) {
Timber.e(ex, "Error parsing intent")
}
try {
handleAppStart()
} catch (ex: Exception) {
Timber.e(ex, "Error during appStart")
setupNavigationManager.navigateTo(SetupDestination.ServerList)
}
}
}
viewModelScope.launchDefault {
// Create the mediaCodecCapabilitiesTest if needed
deviceProfileService.mediaCodecCapabilitiesTest.supportsAVC()
}
}
fun appResume() {
viewModelScope.launchDefault {
mutex.withLock {
try {
Timber.v("Updating userDto")
serverRepository.updateUserDto()
} catch (ex: Exception) {
Timber.w(ex, "Error during appResume")
}
}
}
}
/**
* Handle an [Intent], navigating if needed
*
* @return true if the intent was handled, false if normal app start should proceed
*/
@VisibleForTesting
internal suspend fun handleIntent(intent: Intent?): Boolean {
val result = intent?.let { intentService.parseIntent(intent) }
when (result) {
is IntentResult.Error -> {
val current = serverRepository.current.value
val destination =
if (current != null) {
SetupDestination.UserList(current.server)
} else {
SetupDestination.ServerList
}
setupNavigationManager.navigateTo(destination)
Timber.e("Error parsing intent: %s", result.message)
showToast(context, result.message)
return true
}
is IntentResult.Target -> {
val current = serverRepository.current.value
if (current != null) {
Timber.i("Received valid intent, switching to AppContent")
if (result.addHomeToBackStack) {
navigationManager.reloadHome()
} else {
navigationManager.backStack.clear()
}
navigationManager.backStack.addAll(result.destinations)
setupNavigationManager.navigateTo(
SetupDestination.AppContent(current),
)
} else {
// This should never happen, but just reset app state if it does
setupNavigationManager.navigateTo(SetupDestination.ServerList)
Timber.e("Error parsing intent, no user is active")
showToast(context, "An error occurred parsing the intent")
}
return true
}
IntentResult.NoOp,
null,
-> {
// Not handled
return false
}
}
}
private suspend fun handleAppUpgrade() {
val needUpgrade = appUpgradeHandler.needUpgrade()
if (needUpgrade) {
showToast(
context,
context.getString(
R.string.updated_toast,
appUpgradeHandler.currentVersion.toString(),
),
)
appUpgradeHandler.run()
}
appUpgradeHandler.copySubfont(false)
}
/**
* Handle starting the app including auto sign in and navigating to the right starting page
*/
@VisibleForTesting
internal suspend fun handleAppStart() {
val signInAutomatically = preferences.data.first().signInAutomatically
val currentUser = serverRepository.current.value
if (signInAutomatically && currentUser != null && !currentUser.user.isProtected) {
// Hot reload
Timber.v("Hot reload restoring state")
setupNavigationManager.navigateTo(
SetupDestination.AppContent(currentUser),
)
} else {
val restoredSession =
if (signInAutomatically) {
Timber.v("Sign in automatically")
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")
}
}
}
}
}
}
private val Destination?.isPlayback: Boolean private val Destination?.isPlayback: Boolean
get() = get() =

View file

@ -0,0 +1,263 @@
package com.github.damontecres.wholphin
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.annotation.VisibleForTesting
import androidx.datastore.core.DataStore
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.RestoredSession
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.services.AppUpgradeHandler
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.DeviceProfileService
import com.github.damontecres.wholphin.services.IntentResult
import com.github.damontecres.wholphin.services.IntentService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.services.SetupNavigationManager
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.WholphinDispatchers
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.justinvisser.wolfskin.tv.TrailerReturnRecord
import org.justinvisser.wolfskin.tv.TrailerReturnState
import org.justinvisser.wolfskin.tv.recoverWolfskinSession
import timber.log.Timber
@HiltViewModel
class MainActivityViewModel
@Inject
constructor(
@param:ApplicationContext private val context: Context,
private val preferences: DataStore<AppPreferences>,
val serverRepository: ServerRepository,
private val setupNavigationManager: SetupNavigationManager,
private val navigationManager: NavigationManager,
private val deviceProfileService: DeviceProfileService,
private val backdropService: BackdropService,
private val appUpgradeHandler: AppUpgradeHandler,
private val intentService: IntentService,
private val trailerReturn: TrailerReturnState,
) : ViewModel() {
private val mutex = Mutex()
private var startupJob: kotlinx.coroutines.Job? = null
fun appStart(intent: Intent?, resuming: Boolean = false, savedState: Bundle? = null) {
// onResume clears the pending handoff before asynchronous startup necessarily runs.
val handoff = if (resuming && savedState == null) trailerReturn.pendingHandoff() else null
if (!resuming) trailerReturn.clear()
startupJob?.cancel()
startupJob = viewModelScope.launchDefault {
mutex.withLock {
try {
handleAppUpgrade()
} catch (ex: Exception) {
if (ex is kotlinx.coroutines.CancellationException) throw ex
Timber.e(ex, "Error during app upgrade!")
showToast(
context,
"Error during app upgrade: ${ex.localizedMessage}",
)
}
try {
// A saved trailer task must not replay its original deep link or authenticate its old intent's person.
val restoringTrailer = resuming && savedState?.containsKey(TrailerReturnState.SAVED_STATE_KEY) == true
val result = if (restoringTrailer) false else handleIntent(intent)
if (result) {
Timber.i("appStart was handled by intent")
return@withLock
}
} catch (ex: Exception) {
if (ex is kotlinx.coroutines.CancellationException) throw ex
Timber.e(ex, "Error parsing intent")
}
try {
handleAppStart(resuming, savedState, handoff)
} catch (ex: Exception) {
if (ex is kotlinx.coroutines.CancellationException) throw ex
Timber.e(ex, "Error during appStart")
setupNavigationManager.navigateTo(SetupDestination.ServerList)
}
}
}
viewModelScope.launchDefault {
// Create the mediaCodecCapabilitiesTest if needed
deviceProfileService.mediaCodecCapabilitiesTest.supportsAVC()
}
}
fun appResume() {
viewModelScope.launchDefault {
mutex.withLock {
val expected = serverRepository.current.value
try {
Timber.v("Updating userDto")
serverRepository.updateUserDto()
} catch (ex: Exception) {
Timber.w(ex, "Error during appResume")
recoverWolfskinSession(context, ex, expected, serverRepository, setupNavigationManager)
}
}
}
}
/**
* Handle an [Intent], navigating if needed
*
* @return true if the intent was handled, false if normal app start should proceed
*/
@VisibleForTesting
internal suspend fun handleIntent(intent: Intent?): Boolean {
val result = intent?.let { intentService.parseIntent(intent) }
when (result) {
is IntentResult.Error -> {
val current = serverRepository.current.value
val destination =
if (current != null) {
SetupDestination.UserList(current.server)
} else {
SetupDestination.ServerList
}
setupNavigationManager.navigateTo(destination)
Timber.e("Error parsing intent: %s", result.message)
showToast(context, result.message)
return true
}
is IntentResult.Target -> {
val current = serverRepository.current.value
if (current != null) {
Timber.i("Received valid intent, switching to AppContent")
if (result.addHomeToBackStack) {
navigationManager.reloadHome()
} else {
navigationManager.backStack.clear()
}
navigationManager.backStack.addAll(result.destinations)
setupNavigationManager.navigateTo(
SetupDestination.AppContent(current),
)
} else {
// This should never happen, but just reset app state if it does
setupNavigationManager.navigateTo(SetupDestination.ServerList)
Timber.e("Error parsing intent, no user is active")
showToast(context, "An error occurred parsing the intent")
}
return true
}
IntentResult.NoOp,
null,
-> {
// Not handled
return false
}
}
}
private suspend fun handleAppUpgrade() {
val needUpgrade = appUpgradeHandler.needUpgrade()
if (needUpgrade) {
showToast(
context,
context.getString(
R.string.updated_toast,
appUpgradeHandler.currentVersion.toString(),
),
)
appUpgradeHandler.run()
}
appUpgradeHandler.copySubfont(false)
}
/**
* Handle starting the app including auto sign in and navigating to the right starting page
*/
@VisibleForTesting
internal suspend fun handleAppStart(
resuming: Boolean = false,
savedState: Bundle? = null,
handoff: TrailerReturnRecord? = trailerReturn.pendingHandoff(),
) {
val signInAutomatically = preferences.data.first().signInAutomatically
val currentUser = serverRepository.current.value
val owner = setupNavigationManager.backStack.lastOrNull()
val expected = if (resuming && savedState != null) {
trailerReturn.restore(savedState, navigationManager.backStack.lastOrNull())
} else {
trailerReturn.clear()
null
}
val shown = (owner as? SetupDestination.AppContent)?.current
val canContinue = currentUser != null && shown != null &&
currentUser.server.id == shown.server.id && currentUser.user.id == shown.user.id
if (expected != null) {
val restored = when {
currentUser == null && owner == SetupDestination.Loading ->
serverRepository.restoreSavedSession(expected) {
trailerReturn.isRestoring(expected) && serverRepository.current.value == null &&
setupNavigationManager.backStack.lastOrNull() === owner
}
currentUser != null && (canContinue || owner == SetupDestination.Loading) && !currentUser.user.isProtected &&
currentUser.server.id == expected.serverId && currentUser.user.id == expected.userId &&
serverRepository.isSavedIdentity(expected) -> RestoredSession.Success(currentUser)
else -> serverRepository.getMostRecentServer()
}
finishRestore(restored, owner, fromTrailer = true, revalidate = currentUser == null)
return
}
// A picker may be an explicit switch or expired login, even while a user remains cached.
val continuingTrailer = resuming && savedState == null && canContinue &&
trailerReturn.canContinue(handoff, currentUser, navigationManager.backStack.lastOrNull())
if ((signInAutomatically || continuingTrailer) && currentUser != null && !currentUser.user.isProtected) {
withContext(WholphinDispatchers.Main.immediate) {
if (setupNavigationManager.backStack.lastOrNull() === owner && serverRepository.current.value === currentUser) {
setupNavigationManager.navigateTo(SetupDestination.AppContent(currentUser))
}
}
} else {
val restored = if (signInAutomatically) serverRepository.restoreSavedSession() else serverRepository.getMostRecentServer()
finishRestore(restored, owner, fromTrailer = false, revalidate = true)
}
}
private suspend fun finishRestore(
restored: RestoredSession,
owner: SetupDestination?,
fromTrailer: Boolean,
revalidate: Boolean,
) {
val shown = withContext(WholphinDispatchers.Main.immediate) {
if (setupNavigationManager.backStack.lastOrNull() !== owner) return@withContext false
if (restored is RestoredSession.Success && serverRepository.current.value !== restored.currentUser) return@withContext false
if (fromTrailer && restored is RestoredSession.Success) trailerReturn.confirmRestore(restored.currentUser)
else trailerReturn.clear()
setupNavigationManager.navigateTo(restored.destination)
true
}
if (shown && revalidate && restored is RestoredSession.Success) {
viewModelScope.launchDefault {
try {
serverRepository.revalidateSession(restored.currentUser)
} catch (exception: Exception) {
if (exception is kotlinx.coroutines.CancellationException) throw exception
Timber.w(exception, "Saved launch session revalidation failed")
recoverWolfskinSession(context, exception, restored.currentUser, serverRepository, setupNavigationManager)
}
}
}
}
}

View file

@ -1,6 +1,9 @@
package com.github.damontecres.wholphin package com.github.damontecres.wholphin
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@ -11,17 +14,13 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.NavEntry import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
@ -29,17 +28,22 @@ import androidx.navigation3.ui.NavDisplay
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface import androidx.tv.material3.Surface
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.data.CurrentUser
import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.ScreensaverService import com.github.damontecres.wholphin.services.ScreensaverService
import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.ui.components.AppScreensaver import com.github.damontecres.wholphin.ui.components.AppScreensaver
import org.justinvisser.wolfskin.tv.WolfskinApp import org.justinvisser.wolfskin.tv.WolfskinApp
import org.justinvisser.wolfskin.tv.WolfskinSetupContent
import com.github.damontecres.wholphin.ui.setup.SwitchServerContent import com.github.damontecres.wholphin.ui.setup.SwitchServerContent
import com.github.damontecres.wholphin.ui.setup.SwitchUserContent import com.github.damontecres.wholphin.ui.setup.SwitchUserContent
import com.github.damontecres.wholphin.ui.util.InterfaceCustomization import com.github.damontecres.wholphin.ui.util.InterfaceCustomization
import com.github.damontecres.wholphin.ui.util.LocalInterfaceCustomization import com.github.damontecres.wholphin.ui.util.LocalInterfaceCustomization
import kotlinx.coroutines.flow.StateFlow
import org.justinvisser.wolfskin.tv.TrailerReturnState
/** Setup entries carry display snapshots; lifecycle privacy checks use the live repository identity. */
@Composable @Composable
fun MainContent( fun MainContent(
backStack: MutableList<SetupDestination>, backStack: MutableList<SetupDestination>,
@ -47,7 +51,10 @@ fun MainContent(
userPreferences: UserPreferences, userPreferences: UserPreferences,
backdropService: BackdropService, backdropService: BackdropService,
screensaverService: ScreensaverService, screensaverService: ScreensaverService,
liveSession: StateFlow<CurrentUser?>,
trailerReturn: TrailerReturnState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
setupInterceptor: @Composable (SetupDestination, Modifier) -> Boolean = { key, contentModifier -> WolfskinSetupContent(key, contentModifier) },
) { ) {
val preferences by rememberUpdatedState(userPreferences) val preferences by rememberUpdatedState(userPreferences)
Surface( Surface(
@ -64,6 +71,9 @@ fun MainContent(
NavDisplay( NavDisplay(
backStack = backStack, backStack = backStack,
onBack = { backStack.removeLastOrNull() }, onBack = { backStack.removeLastOrNull() },
transitionSpec = { (EnterTransition.None togetherWith ExitTransition.None).using(null) },
popTransitionSpec = { (EnterTransition.None togetherWith ExitTransition.None).using(null) },
predictivePopTransitionSpec = { _ -> (EnterTransition.None togetherWith ExitTransition.None).using(null) },
entryDecorators = entryDecorators =
listOf( listOf(
rememberSaveableStateHolderNavEntryDecorator(), rememberSaveableStateHolderNavEntryDecorator(),
@ -72,6 +82,7 @@ fun MainContent(
entryProvider = { key -> entryProvider = { key ->
key as SetupDestination key as SetupDestination
NavEntry(key) { NavEntry(key) {
if (setupInterceptor(key, Modifier.fillMaxSize())) return@NavEntry
when (key) { when (key) {
SetupDestination.Loading -> { SetupDestination.Loading -> {
Box( Box(
@ -101,16 +112,8 @@ fun MainContent(
backdropService.clearBackdrop() backdropService.clearBackdrop()
} }
val current = key.current val current = key.current
var showContent by remember { SignedInContent(current, liveSession, preferences.appPreferences.signInAutomatically,
mutableStateOf(true) trailerReturn, { navigationManager.backStack.lastOrNull() }) {
}
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
if (!userPreferences.appPreferences.signInAutomatically) {
showContent = false
}
}
if (showContent) {
WolfskinApp( WolfskinApp(
user = current.user, user = current.user,
server = current.server, server = current.server,
@ -118,16 +121,6 @@ fun MainContent(
preferences = preferences, preferences = preferences,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
} else {
Box(
modifier = Modifier.size(200.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.border,
modifier = Modifier.align(Alignment.Center),
)
}
} }
} }
} }

View file

@ -0,0 +1,63 @@
package com.github.damontecres.wholphin
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
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.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.tv.material3.MaterialTheme
import androidx.lifecycle.compose.LifecycleEventEffect
import com.github.damontecres.wholphin.data.CurrentUser
import kotlinx.coroutines.flow.StateFlow
import com.github.damontecres.wholphin.ui.nav.Destination
import org.justinvisser.wolfskin.tv.TrailerReturnRecord
import org.justinvisser.wolfskin.tv.TrailerReturnState
/** Only a pending trailer handoff keeps an auto-off session mounted across a stopped activity. */
@Composable
internal fun SignedInContent(
current: CurrentUser,
liveSession: StateFlow<CurrentUser?>,
signInAutomatically: Boolean,
trailerReturn: TrailerReturnState,
destination: () -> Destination?,
content: @Composable () -> Unit,
) {
var showContent by remember(current.server.id, current.user.id) { mutableStateOf(true) }
var hasStopped by remember(current.server.id, current.user.id) { mutableStateOf(false) }
var handoff by remember(current.server.id, current.user.id) { mutableStateOf<TrailerReturnRecord?>(null) }
fun hideInvalidSession() {
if (!signInAutomatically) {
val live = liveSession.value
val canContinue = live != null && live.server.id == current.server.id &&
live.user.id == current.user.id && trailerReturn.canContinue(handoff, live, destination())
if (!canContinue) showContent = false
}
}
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
hasStopped = true
handoff = trailerReturn.pendingHandoff()
hideInvalidSession()
}
LifecycleEventEffect(Lifecycle.Event.ON_START) {
// A newly unlocked entry may paint; a returning entry must recheck protection changed while away.
if (hasStopped) {
hideInvalidSession()
hasStopped = false
}
}
if (showContent) {
content()
} else {
Box(Modifier.size(200.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.border, modifier = Modifier.align(Alignment.Center))
}
}
}

View file

@ -10,6 +10,8 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -120,12 +122,35 @@ class ServerRepository
} }
/** Restore only an unprotected saved login; the launch picker must not wait for the network. */ /** Restore only an unprotected saved login; the launch picker must not wait for the network. */
suspend fun restoreSavedSession(): RestoredSession = withContext(ioDispatcher) { suspend fun restoreSavedSession(): RestoredSession = restoreSavedSessionInternal(null, null)
/** A task-bound restore cannot select another saved person or outlive its caller's authorization. */
suspend fun restoreSavedSession(
expected: SavedSessionIdentity,
isStillAuthorized: () -> Boolean,
): RestoredSession = restoreSavedSessionInternal(expected, isStillAuthorized)
/** Clearing/changing the saved login also invalidates a surviving trailer activity's handoff. */
fun isSavedIdentity(expected: SavedSessionIdentity): Boolean =
matchesSavedIdentity(mostRecentServerProvider.get(), expected)
private fun matchesSavedIdentity(recent: MostRecentServer, expected: SavedSessionIdentity): Boolean =
recent is MostRecentServer.ServerAndUser && recent.serverId == expected.serverId && recent.userId == expected.userId
private suspend fun restoreSavedSessionInternal(
expected: SavedSessionIdentity?,
isStillAuthorized: (() -> Boolean)?,
): RestoredSession = withContext(ioDispatcher) {
val recent = mostRecentServerProvider.get() val recent = mostRecentServerProvider.get()
if (expected != null && !matchesSavedIdentity(recent, expected)) return@withContext getMostRecentServerInternal(recent)
if (recent !is MostRecentServer.ServerAndUser) return@withContext getMostRecentServerInternal(recent) if (recent !is MostRecentServer.ServerAndUser) return@withContext getMostRecentServerInternal(recent)
val saved = serverDao.getServer(recent.serverId) ?: return@withContext RestoredSession.None val saved = serverDao.getServer(recent.serverId) ?: return@withContext RestoredSession.None
val user = saved.users.firstOrNull { it.id == recent.userId } val user = saved.users.firstOrNull { it.id == recent.userId }
if (user == null || user.isProtected) return@withContext RestoredSession.ServerOnly(saved.server) if (user == null || user.isProtected || (expected != null && user.accessToken.isNullOrBlank())) {
return@withContext RestoredSession.ServerOnly(saved.server)
}
currentCoroutineContext().ensureActive()
if (isStillAuthorized?.invoke() == false) return@withContext RestoredSession.ServerOnly(saved.server)
apiClient.update(baseUrl = saved.server.url, accessToken = user.accessToken) apiClient.update(baseUrl = saved.server.url, accessToken = user.accessToken)
val current = CurrentUser(saved.server, user) val current = CurrentUser(saved.server, user)
_currentUserDto.value = null _currentUserDto.value = null
@ -384,3 +409,6 @@ data class CurrentUser(
val server: JellyfinServer, val server: JellyfinServer,
val user: JellyfinUser, val user: JellyfinUser,
) )
/** A previously authenticated server/person pair, without credentials or protection state. */
data class SavedSessionIdentity(val serverId: UUID, val userId: UUID)

View file

@ -29,7 +29,7 @@ sealed interface HomeRowConfig {
fun updateViewOptions(viewOptions: HomeRowViewOptions): HomeRowConfig fun updateViewOptions(viewOptions: HomeRowViewOptions): HomeRowConfig
/** /**
* Continue watching media that the user has started but not finished * Legacy serialized resume row, migrated to ContinueWatchingCombined when settings are read.
*/ */
@Serializable @Serializable
@SerialName("ContinueWatching") @SerialName("ContinueWatching")
@ -40,7 +40,7 @@ sealed interface HomeRowConfig {
} }
/** /**
* Next up row for next episodes in a series the user has started * Legacy serialized next-up row, migrated to ContinueWatchingCombined when settings are read.
*/ */
@Serializable @Serializable
@SerialName("NextUp") @SerialName("NextUp")

View file

@ -0,0 +1,15 @@
package com.github.damontecres.wholphin.data.model
/** Saved split rows become one watch row at their first position, retaining its presentation choices. */
fun mergeHomeWatchRows(rows: List<HomeRowConfig>): List<HomeRowConfig> = buildList {
var watchRowSeen = false
for (row in rows) {
when (row) {
is HomeRowConfig.ContinueWatching, is HomeRowConfig.NextUp, is HomeRowConfig.ContinueWatchingCombined -> {
if (!watchRowSeen) add(HomeRowConfig.ContinueWatchingCombined(row.viewOptions))
watchRowSeen = true
}
else -> add(row)
}
}
}

View file

@ -8,7 +8,6 @@ import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
import com.github.damontecres.wholphin.ui.collectLatestIn import com.github.damontecres.wholphin.ui.collectLatestIn
import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler
import com.github.damontecres.wholphin.util.WholphinDispatchers import com.github.damontecres.wholphin.util.WholphinDispatchers
import com.google.common.cache.CacheBuilder import com.google.common.cache.CacheBuilder
import dagger.hilt.android.qualifiers.ActivityContext import dagger.hilt.android.qualifiers.ActivityContext
@ -21,13 +20,15 @@ import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.InvalidStatusException import org.jellyfin.sdk.api.client.exception.InvalidStatusException
import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.request.GetEpisodesRequest import org.jellyfin.sdk.api.client.extensions.itemsApi
import timber.log.Timber import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
import java.time.LocalDateTime import java.time.LocalDateTime
import java.util.UUID import java.util.UUID
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import timber.log.Timber
/** /**
* Caches when media was lasted played. This is mostly used by the combined Continue Watching & Next Up home row. * Caches when media was lasted played. This is mostly used by the combined Continue Watching & Next Up home row.
@ -46,50 +47,21 @@ class DatePlayedService
.expireAfterWrite(2, TimeUnit.HOURS) .expireAfterWrite(2, TimeUnit.HOURS)
.build<SeriesItemId, Deferred<LocalDateTime>>() .build<SeriesItemId, Deferred<LocalDateTime>>()
private fun getLastPlayed( private fun readLastPlayed(seriesId: UUID): Deferred<LocalDateTime> =
seriesId: UUID, scope.async(WholphinDispatchers.IO) {
item: BaseItem,
): Deferred<LocalDateTime> {
val request =
GetEpisodesRequest(
seriesId = seriesId,
adjacentTo = item.id,
limit = 1,
)
return scope.async(WholphinDispatchers.IO) {
val premiereDate = item.data.premiereDate
try { try {
val result = api.itemsApi.getItems(parentId = seriesId, recursive = true,
GetEpisodesRequestHandler includeItemTypes = listOf(BaseItemKind.EPISODE), isPlayed = true, enableUserData = true,
.execute( sortBy = listOf(ItemSortBy.DATE_PLAYED), sortOrder = listOf(SortOrder.DESCENDING), limit = 1)
api, .content.items.firstOrNull()?.userData?.lastPlayedDate ?: LocalDateTime.MIN
request,
).content.items
.firstOrNull()
?.userData
?.lastPlayedDate ?: LocalDateTime.MIN
if (premiereDate != null && result.isBefore(premiereDate)) {
premiereDate
} else {
result
}
} catch (ex: InvalidStatusException) { } catch (ex: InvalidStatusException) {
Timber.w( Timber.w(ex, "Error fetching series=%s", seriesId)
"Error fetching series=%s, item=%s: %s", LocalDateTime.MIN
seriesId,
item.id,
ex.localizedMessage,
)
LocalDateTime.MIN.coerceAtLeast(premiereDate ?: LocalDateTime.MIN)
} }
} }
}
/** /**
* Get the logical timestamp when the item was last played. * Get the series' latest actual watch activity, never an upcoming episode's premiere date.
*
* This is calculated using lastest of the actual last played timestamp of the item,
* the previous episode's last played timestamp, and the episode's premiere date
*/ */
suspend fun getLastPlayed(item: BaseItem): LocalDateTime? = suspend fun getLastPlayed(item: BaseItem): LocalDateTime? =
withContext(WholphinDispatchers.IO) { withContext(WholphinDispatchers.IO) {
@ -97,7 +69,7 @@ class DatePlayedService
return@withContext if (seriesId != null) { return@withContext if (seriesId != null) {
datePlayedCache datePlayedCache
.get(SeriesItemId(seriesId, item.id)) { .get(SeriesItemId(seriesId, item.id)) {
getLastPlayed(seriesId, item) readLastPlayed(seriesId)
}.await() }.await()
} else { } else {
null null

View file

@ -92,44 +92,8 @@ class HomeDataService
usePaging: Boolean = false, usePaging: Boolean = false,
): HomeRowLoadingState = ): HomeRowLoadingState =
when (row) { when (row) {
is HomeRowConfig.ContinueWatching -> { is HomeRowConfig.ContinueWatching,
val resume = is HomeRowConfig.NextUp,
latestNextUpService.getResume(
userDto.id,
limit,
true,
row.viewOptions.useSeries,
)
Success(
title = ResStringProvider(R.string.continue_watching),
items = resume,
viewOptions = row.viewOptions,
rowType = row,
showViewMore = resume.size >= limit,
)
}
is HomeRowConfig.NextUp -> {
val nextUp =
latestNextUpService.getNextUp(
userDto.id,
limit,
prefs.enableRewatchingNextUp,
false,
prefs.maxDaysNextUp,
row.viewOptions.useSeries,
)
Success(
title = ResStringProvider(R.string.next_up),
items = nextUp,
viewOptions = row.viewOptions,
rowType = row,
showViewMore = nextUp.size >= limit,
)
}
is HomeRowConfig.ContinueWatchingCombined -> { is HomeRowConfig.ContinueWatchingCombined -> {
val resume = val resume =
latestNextUpService.getResume( latestNextUpService.getResume(

View file

@ -11,6 +11,7 @@ import com.github.damontecres.wholphin.data.model.HomeRowConfig
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION
import com.github.damontecres.wholphin.data.model.mergeHomeWatchRows
import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration
import com.github.damontecres.wholphin.ui.AspectRatio import com.github.damontecres.wholphin.ui.AspectRatio
import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.Cards
@ -159,7 +160,7 @@ class HomeSettingsService
null null
} }
}.orEmpty() }.orEmpty()
return HomePageSettings(rows, version) return HomePageSettings(mergeHomeWatchRows(rows), version)
} }
private suspend fun tryLoad(block: suspend () -> HomePageSettings?): HomePageSettings? = private suspend fun tryLoad(block: suspend () -> HomePageSettings?): HomePageSettings? =
@ -191,7 +192,7 @@ class HomeSettingsService
) )
return settings?.let { return settings?.let {
val resolvedRows = val resolvedRows =
settings.rows.mapIndexed { index, config -> mergeHomeWatchRows(settings.rows).mapIndexed { index, config ->
resolve(index, config) resolve(index, config)
} }
HomePageResolvedSettings(userId, resolvedSource, resolvedRows) HomePageResolvedSettings(userId, resolvedSource, resolvedRows)
@ -261,7 +262,7 @@ class HomeSettingsService
settings: HomePageSettings, settings: HomePageSettings,
) { ) {
val resolvedRows = val resolvedRows =
settings.rows.mapIndexed { index, config -> mergeHomeWatchRows(settings.rows).mapIndexed { index, config ->
resolve(index, config) resolve(index, config)
} }
currentSettings.update { it.copy(rows = resolvedRows) } currentSettings.update { it.copy(rows = resolvedRows) }
@ -313,7 +314,7 @@ class HomeSettingsService
listOf( listOf(
HomeRowConfigDisplay( HomeRowConfigDisplay(
id = includedIds.size + 1, id = includedIds.size + 1,
title = ResStringProvider(R.string.combine_continue_next), title = ResStringProvider(R.string.continue_watching),
config = HomeRowConfig.ContinueWatchingCombined(), config = HomeRowConfig.ContinueWatchingCombined(),
), ),
) )
@ -434,7 +435,8 @@ class HomeSettingsService
null null
} }
}.flatten() }.flatten()
HomePageResolvedSettings(userId, HomePageSettingsSource.WEB_CONFIG, rowConfigs) val merged = mergeHomeWatchRows(rowConfigs.map { it.config }).mapIndexed { index, config -> resolve(index, config) }
HomePageResolvedSettings(userId, HomePageSettingsSource.WEB_CONFIG, merged)
} else { } else {
null null
} }
@ -457,19 +459,13 @@ class HomeSettingsService
) )
} }
is HomeRowConfig.ContinueWatching -> { is HomeRowConfig.ContinueWatching,
HomeRowConfigDisplay( is HomeRowConfig.NextUp,
id,
ResStringProvider(R.string.continue_watching),
config,
)
}
is HomeRowConfig.ContinueWatchingCombined -> { is HomeRowConfig.ContinueWatchingCombined -> {
HomeRowConfigDisplay( HomeRowConfigDisplay(
id, id,
ResStringProvider(R.string.combine_continue_next), ResStringProvider(R.string.continue_watching),
config, HomeRowConfig.ContinueWatchingCombined(config.viewOptions),
) )
} }
@ -495,14 +491,6 @@ class HomeSettingsService
HomeRowConfigDisplay(id, StringStringProvider(config.name), config) HomeRowConfigDisplay(id, StringStringProvider(config.name), config)
} }
is HomeRowConfig.NextUp -> {
HomeRowConfigDisplay(
id,
ResStringProvider(R.string.next_up),
config,
)
}
is HomeRowConfig.RecentlyAdded -> { is HomeRowConfig.RecentlyAdded -> {
val title = getItemName(R.string.recently_added_in, config.parentId) val title = getItemName(R.string.recently_added_in, config.parentId)
HomeRowConfigDisplay( HomeRowConfigDisplay(

View file

@ -158,7 +158,7 @@ class LatestNextUpService
val timestamps = mutableMapOf<UUID, LocalDateTime?>() val timestamps = mutableMapOf<UUID, LocalDateTime?>()
nextUp.map { it.id }.zip(nextUpLastPlayed).toMap(timestamps) nextUp.map { it.id }.zip(nextUpLastPlayed).toMap(timestamps)
resume.forEach { timestamps[it.id] = it.data.userData?.lastPlayedDate } resume.forEach { timestamps[it.id] = it.data.userData?.lastPlayedDate }
val result = (resume + nextUp).sortedByDescending { timestamps[it.id] } val result = (resume + nextUp).distinctBy { it.data.seriesId ?: it.id }.sortedByDescending { timestamps[it.id] }
val duration = (System.currentTimeMillis() - start).milliseconds val duration = (System.currentTimeMillis() - start).milliseconds
Timber.v("buildCombined took %s", duration) Timber.v("buildCombined took %s", duration)
return@withContext result return@withContext result

View file

@ -2,6 +2,8 @@ package com.github.damontecres.wholphin.services
import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavBackStack
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.receiveAsFlow
import org.acra.ACRA import org.acra.ACRA
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@ -15,6 +17,11 @@ class NavigationManager
@Inject @Inject
constructor() { constructor() {
var backStack: MutableList<Destination> = NavBackStack(Destination.Home()) var backStack: MutableList<Destination> = NavBackStack(Destination.Home())
private val exits = Channel<Unit>(Channel.CONFLATED)
val exitRequests = exits.receiveAsFlow()
/** Finish the containing task; never expose an older setup/user entry. */
fun requestExit() { exits.trySend(Unit) }
/** /**
* Go to the specified [com.github.damontecres.wholphin.ui.nav.Destination] * Go to the specified [com.github.damontecres.wholphin.ui.nav.Destination]
@ -41,12 +48,14 @@ class NavigationManager
} }
/** /**
* Go to the previous page * Go to the previous page, or exit when there is no parent.
*/ */
fun goBack() { fun goBack() {
synchronized(this) { synchronized(this) {
if (backStack.size > 1) { if (backStack.size > 1) {
backStack.removeLastOrNull() backStack.removeLastOrNull()
} else {
requestExit()
} }
} }
log() log()

View file

@ -54,6 +54,7 @@ import timber.log.Timber
import java.lang.reflect.Constructor import java.lang.reflect.Constructor
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.time.Duration
/** /**
* Constructs a [Player] instance for video playback * Constructs a [Player] instance for video playback
@ -174,7 +175,7 @@ class PlayerFactory
ExoPlayer ExoPlayer
.Builder(context) .Builder(context)
.setMediaSourceFactory(mediaSourceFactory) .setMediaSourceFactory(mediaSourceFactory)
.setRenderersFactory(renderersFactory) .setRenderersFactory(SubtitleDelayRenderersFactory(renderersFactory))
.setTrackSelector(trackSelector) .setTrackSelector(trackSelector)
.build() .build()
.apply { .apply {
@ -303,6 +304,29 @@ val Player.isReleased: Boolean
} }
} }
/**
* Delays the subtitles of this player by [delay]; a positive delay shows them later
*/
fun Player.applySubtitleDelay(delay: Duration) {
when (this) {
is MpvPlayer -> {
subtitleDelay = delay
}
is ExoPlayer -> {
for (index in 0 until rendererCount) {
val renderer = getRenderer(index)
if (renderer is SubtitleDelayRenderer) {
createMessage(renderer)
.setType(SubtitleDelayRenderer.MSG_SET_SUBTITLE_DELAY)
.setPayload(delay.inWholeMicroseconds)
.send()
}
}
}
}
}
data class PlayerCreation( data class PlayerCreation(
val player: Player, val player: Player,
val assHandler: AssHandler? = null, val assHandler: AssHandler? = null,

View file

@ -0,0 +1,216 @@
@file:OptIn(markerClass = [UnstableApi::class])
package com.github.damontecres.wholphin.services
import android.os.Handler
import androidx.annotation.OptIn
import androidx.media3.common.C
import androidx.media3.common.Format
import androidx.media3.common.Timeline
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ForwardingRenderer
import androidx.media3.exoplayer.Renderer
import androidx.media3.exoplayer.RendererConfiguration
import androidx.media3.exoplayer.RenderersFactory
import androidx.media3.exoplayer.audio.AudioRendererEventListener
import androidx.media3.exoplayer.metadata.MetadataOutput
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.SampleStream
import androidx.media3.exoplayer.text.TextOutput
import androidx.media3.exoplayer.video.VideoRendererEventListener
import io.github.peerless2012.ass.media.render.AssRenderer
/**
* Delays the subtitles of the wrapped renderer by shifting the playback position it is given.
*
* A positive delay shows subtitles later, a negative delay earlier, the same as mpv's `sub-delay`.
* The delay is set on the playback thread with a [MSG_SET_SUBTITLE_DELAY] message whose payload is
* the delay in microseconds as a [Long].
*
* Wrapping hides the renderer's type from ExoPlayer's `instanceof TextRenderer` checks, which only
* matter for clipped streams and server-side inserted ads.
*/
class SubtitleDelayRenderer(
internal val delegate: Renderer,
) : ForwardingRenderer(delegate) {
private val period = Timeline.Period()
private var delayUs = 0L
private var timeline: Timeline = Timeline.EMPTY
private var mediaPeriodId: MediaSource.MediaPeriodId? = null
private var streamOffsetUs = 0L
private var periodEndPositionUs = C.TIME_UNSET
private var lastPositionUs = C.TIME_UNSET
override fun handleMessage(
messageType: Int,
message: Any?,
) {
if (messageType == MSG_SET_SUBTITLE_DELAY) {
delayUs = message as Long
} else {
super.handleMessage(messageType, message)
}
}
override fun enable(
configuration: RendererConfiguration,
formats: Array<out Format>,
stream: SampleStream,
positionUs: Long,
joining: Boolean,
mayRenderStartOfStream: Boolean,
startPositionUs: Long,
offsetUs: Long,
mediaPeriodId: MediaSource.MediaPeriodId,
) {
lastPositionUs = positionUs
updateStream(offsetUs, mediaPeriodId)
super.enable(
configuration,
formats,
stream,
positionUs - delayUs,
joining,
mayRenderStartOfStream,
startPositionUs,
offsetUs,
mediaPeriodId,
)
}
override fun replaceStream(
formats: Array<out Format>,
stream: SampleStream,
startPositionUs: Long,
offsetUs: Long,
mediaPeriodId: MediaSource.MediaPeriodId,
) {
updateStream(offsetUs, mediaPeriodId)
super.replaceStream(formats, stream, startPositionUs, offsetUs, mediaPeriodId)
}
override fun setTimeline(timeline: Timeline) {
this.timeline = timeline
updatePeriodEndPosition()
super.setTimeline(timeline)
}
override fun render(
positionUs: Long,
elapsedRealtimeUs: Long,
) {
lastPositionUs = positionUs
super.render(positionUs - delayUs, elapsedRealtimeUs)
}
override fun resetPosition(
positionUs: Long,
sampleStreamIsResetToKeyFrame: Boolean,
) {
lastPositionUs = positionUs
super.resetPosition(positionUs - delayUs, sampleStreamIsResetToKeyFrame)
}
override fun supportsResetPositionWithoutKeyFrameReset(positionUs: Long): Boolean =
super.supportsResetPositionWithoutKeyFrameReset(positionUs - delayUs)
override fun getDurationToProgressUs(
positionUs: Long,
elapsedRealtimeUs: Long,
): Long = super.getDurationToProgressUs(positionUs - delayUs, elapsedRealtimeUs)
/**
* A text renderer only reads the end of its stream once its position is within a second of the
* period end, so a positive delay above a second would hold back
* [androidx.media3.common.Player.STATE_ENDED] by the delay minus that second. Once the real
* position passes the period end there is nothing left to show, so the renderer counts as ended.
*/
override fun isEnded(): Boolean =
super.isEnded() ||
(
delayUs > 0 &&
periodEndPositionUs != C.TIME_UNSET &&
lastPositionUs >= periodEndPositionUs
)
override fun disable() {
mediaPeriodId = null
periodEndPositionUs = C.TIME_UNSET
super.disable()
}
private fun updateStream(
offsetUs: Long,
mediaPeriodId: MediaSource.MediaPeriodId,
) {
streamOffsetUs = offsetUs
this.mediaPeriodId = mediaPeriodId
updatePeriodEndPosition()
}
private fun updatePeriodEndPosition() {
val periodIndex = mediaPeriodId?.let { timeline.getIndexOfPeriod(it.periodUid) } ?: C.INDEX_UNSET
val durationUs =
if (periodIndex == C.INDEX_UNSET) {
C.TIME_UNSET
} else {
timeline.getPeriod(periodIndex, period).durationUs
}
periodEndPositionUs = if (durationUs == C.TIME_UNSET) C.TIME_UNSET else streamOffsetUs + durationUs
}
companion object {
/** Sets the subtitle delay; the payload is the delay in microseconds as a [Long]. */
const val MSG_SET_SUBTITLE_DELAY = Renderer.MSG_CUSTOM_BASE
}
}
/**
* Wraps the subtitle renderers of [delegate] in a [SubtitleDelayRenderer]: media3's text renderer
* and the libass [AssRenderer], which draws at the position it is rendered with.
*/
class SubtitleDelayRenderersFactory(
private val delegate: RenderersFactory,
) : RenderersFactory {
override fun createRenderers(
eventHandler: Handler,
videoRendererEventListener: VideoRendererEventListener,
audioRendererEventListener: AudioRendererEventListener,
textRendererOutput: TextOutput,
metadataRendererOutput: MetadataOutput,
): Array<Renderer> {
val renderers =
delegate.createRenderers(
eventHandler,
videoRendererEventListener,
audioRendererEventListener,
textRendererOutput,
metadataRendererOutput,
)
return Array(renderers.size) { index ->
val renderer = renderers[index]
if (renderer.trackType == C.TRACK_TYPE_TEXT || renderer is AssRenderer) {
SubtitleDelayRenderer(renderer)
} else {
renderer
}
}
}
override fun createSecondaryRenderer(
renderer: Renderer,
eventHandler: Handler,
videoRendererEventListener: VideoRendererEventListener,
audioRendererEventListener: AudioRendererEventListener,
textRendererOutput: TextOutput,
metadataRendererOutput: MetadataOutput,
): Renderer? =
delegate.createSecondaryRenderer(
(renderer as? SubtitleDelayRenderer)?.delegate ?: renderer,
eventHandler,
videoRendererEventListener,
audioRendererEventListener,
textRendererOutput,
metadataRendererOutput,
)
}

View file

@ -1,7 +0,0 @@
package com.github.damontecres.wholphin.ui.main.settings
sealed interface HomeRowConfigAction {
data object Combine : HomeRowConfigAction
data object Split : HomeRowConfigAction
}

View file

@ -60,7 +60,6 @@ fun HomeRowSettings(
viewOptions: HomeRowViewOptions, viewOptions: HomeRowViewOptions,
onViewOptionsChange: (HomeRowViewOptions) -> Unit, onViewOptionsChange: (HomeRowViewOptions) -> Unit,
onConfigChange: (HomeRowConfig) -> Unit, onConfigChange: (HomeRowConfig) -> Unit,
onConfigAction: (HomeRowConfigAction) -> Unit,
onApplyApplyAll: () -> Unit, onApplyApplyAll: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
defaultViewOptions: HomeRowViewOptions = HomeRowViewOptions(), defaultViewOptions: HomeRowViewOptions = HomeRowViewOptions(),
@ -126,7 +125,6 @@ fun HomeRowSettings(
addAdditionalConfig( addAdditionalConfig(
config = config, config = config,
onConfigChange = onConfigChange, onConfigChange = onConfigChange,
onConfigAction = onConfigAction,
) )
} }
} }
@ -135,29 +133,8 @@ fun HomeRowSettings(
private fun LazyListScope.addAdditionalConfig( private fun LazyListScope.addAdditionalConfig(
config: HomeRowConfig, config: HomeRowConfig,
onConfigChange: (HomeRowConfig) -> Unit, onConfigChange: (HomeRowConfig) -> Unit,
onConfigAction: (HomeRowConfigAction) -> Unit,
) { ) {
when (config) { when (config) {
is HomeRowConfig.ContinueWatchingCombined -> {
item {
ClickPreference(
title = stringResource(R.string.split_into_separate_rows),
onClick = { onConfigAction.invoke(HomeRowConfigAction.Split) },
)
}
}
is HomeRowConfig.ContinueWatching,
is HomeRowConfig.NextUp,
-> {
item {
ClickPreference(
title = stringResource(R.string.combine_continue_next),
onClick = { onConfigAction.invoke(HomeRowConfigAction.Combine) },
)
}
}
is HomeRowConfig.ByParent -> { is HomeRowConfig.ByParent -> {
item { item {
val sortAndDirection = val sortAndDirection =

View file

@ -35,8 +35,6 @@ fun HomeSettingsAddRow(
) { ) {
itemsIndexed( itemsIndexed(
listOf( listOf(
MetaRowType.CONTINUE_WATCHING,
MetaRowType.NEXT_UP,
MetaRowType.COMBINED_CONTINUE_WATCHING, MetaRowType.COMBINED_CONTINUE_WATCHING,
), ),
) { index, type -> ) { index, type ->
@ -94,9 +92,7 @@ fun HomeSettingsAddRow(
enum class MetaRowType( enum class MetaRowType(
@param:StringRes val stringId: Int, @param:StringRes val stringId: Int,
) { ) {
CONTINUE_WATCHING(R.string.continue_watching), COMBINED_CONTINUE_WATCHING(R.string.continue_watching),
NEXT_UP(R.string.next_up),
COMBINED_CONTINUE_WATCHING(R.string.combine_continue_next),
FAVORITES(R.string.favorites), FAVORITES(R.string.favorites),
DISCOVER(R.string.discover), DISCOVER(R.string.discover),
COLLECTION(R.string.collection), COLLECTION(R.string.collection),

View file

@ -157,8 +157,6 @@ fun HomeSettingsPage(
onClick = { backStack.add(ChooseRowType(it)) }, onClick = { backStack.add(ChooseRowType(it)) },
onClickMeta = { onClickMeta = {
when (it) { when (it) {
MetaRowType.CONTINUE_WATCHING,
MetaRowType.NEXT_UP,
MetaRowType.COMBINED_CONTINUE_WATCHING, MetaRowType.COMBINED_CONTINUE_WATCHING,
-> { -> {
addRow { viewModel.addRow(it) } addRow { viewModel.addRow(it) }
@ -246,18 +244,6 @@ fun HomeSettingsPage(
onConfigChange = { onConfigChange = {
viewModel.onConfigChange(row, it) viewModel.onConfigChange(row, it)
}, },
onConfigAction = {
viewModel.onConfigAction(row, it)
when (it) {
HomeRowConfigAction.Combine,
HomeRowConfigAction.Split,
-> {
backStack.removeAt(
backStack.lastIndex,
)
}
}
},
) )
} }

View file

@ -22,6 +22,7 @@ import com.github.damontecres.wholphin.data.model.HomeRowConfig.TvChannels
import com.github.damontecres.wholphin.data.model.HomeRowConfig.TvPrograms import com.github.damontecres.wholphin.data.model.HomeRowConfig.TvPrograms
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION
import com.github.damontecres.wholphin.data.model.mergeHomeWatchRows
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.HomeDataService import com.github.damontecres.wholphin.services.HomeDataService
@ -249,26 +250,10 @@ class HomeSettingsViewModel
val id = idCounter++ val id = idCounter++
val newRow = val newRow =
when (type) { when (type) {
MetaRowType.CONTINUE_WATCHING -> {
HomeRowConfigDisplay(
id = id,
title = ResStringProvider(R.string.continue_watching),
config = ContinueWatching(),
)
}
MetaRowType.NEXT_UP -> {
HomeRowConfigDisplay(
id = id,
title = ResStringProvider(R.string.next_up),
config = NextUp(),
)
}
MetaRowType.COMBINED_CONTINUE_WATCHING -> { MetaRowType.COMBINED_CONTINUE_WATCHING -> {
HomeRowConfigDisplay( HomeRowConfigDisplay(
id = id, id = id,
title = ResStringProvider(R.string.combine_continue_next), title = ResStringProvider(R.string.continue_watching),
config = ContinueWatchingCombined(), config = ContinueWatchingCombined(),
) )
} }
@ -813,7 +798,7 @@ class HomeSettingsViewModel
if (result != null) { if (result != null) {
Timber.v("Got remote settings") Timber.v("Got remote settings")
val newRows = val newRows =
result.rows.mapIndexed { index, config -> mergeHomeWatchRows(result.rows).mapIndexed { index, config ->
homeSettingsService.resolve(index, config) homeSettingsService.resolve(index, config)
} }
idCounter = newRows.maxOfOrNull { it.id }?.plus(1) ?: 0 idCounter = newRows.maxOfOrNull { it.id }?.plus(1) ?: 0
@ -839,76 +824,6 @@ class HomeSettingsViewModel
} }
} }
fun onConfigAction(
row: HomeRowConfigDisplay,
action: HomeRowConfigAction,
) {
viewModelScope.launchDefault {
when (action) {
HomeRowConfigAction.Combine -> {
val index =
state.value.rows
.indexOf(row)
.coerceAtLeast(0)
val rowsToRemove =
state.value.rows
.filter { it.config is ContinueWatching || it.config is NextUp }
updateState {
it.copy(
loading = LoadingState.Loading,
rows =
it.rows.toMutableList().apply {
set(
index,
HomeRowConfigDisplay(
id = it.rows[index].id,
title = ResStringProvider(R.string.combine_continue_next),
config = ContinueWatchingCombined(row.config.viewOptions),
),
)
removeAll(rowsToRemove)
},
)
}
fetchRowData()
}
HomeRowConfigAction.Split -> {
val index =
state.value.rows
.indexOf(row)
.coerceAtLeast(0)
updateState {
it.copy(
loading = LoadingState.Loading,
rows =
it.rows.toMutableList().apply {
set(
index,
HomeRowConfigDisplay(
id = it.rows[index].id,
title = ResStringProvider(R.string.continue_watching),
config = ContinueWatching(row.config.viewOptions),
),
)
add(
index + 1,
HomeRowConfigDisplay(
id = idCounter++,
title = ResStringProvider(R.string.next_up),
config = NextUp(row.config.viewOptions),
),
)
},
)
}
fetchRowData()
}
}
}
}
fun onConfigChange( fun onConfigChange(
row: HomeRowConfigDisplay, row: HomeRowConfigDisplay,
config: HomeRowConfig, config: HomeRowConfig,

View file

@ -60,7 +60,9 @@ import androidx.compose.ui.window.DialogProperties
import androidx.core.view.children import androidx.core.view.children
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleStartEffect import androidx.lifecycle.compose.LifecycleStartEffect
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.SubtitleView import androidx.media3.ui.SubtitleView
import androidx.media3.ui.compose.PlayerSurface import androidx.media3.ui.compose.PlayerSurface
import androidx.media3.ui.compose.SURFACE_TYPE_SURFACE_VIEW import androidx.media3.ui.compose.SURFACE_TYPE_SURFACE_VIEW
@ -75,6 +77,7 @@ import com.github.damontecres.wholphin.preferences.DpadSeekMode
import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.preferences.skipBackOnResume import com.github.damontecres.wholphin.preferences.skipBackOnResume
import com.github.damontecres.wholphin.services.applySubtitleDelay
import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.LocalImageUrlService import com.github.damontecres.wholphin.ui.LocalImageUrlService
@ -99,6 +102,8 @@ import com.github.damontecres.wholphin.util.Media3SubtitleOverride
import io.github.peerless2012.ass.media.widget.AssSubtitleView import io.github.peerless2012.ass.media.widget.AssSubtitleView
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
import timber.log.Timber import timber.log.Timber
import kotlin.time.Duration import kotlin.time.Duration
@ -185,9 +190,8 @@ fun PlaybackPageContent(
var playbackSpeed by remember { mutableFloatStateOf(1.0f) } var playbackSpeed by remember { mutableFloatStateOf(1.0f) }
LaunchedEffect(playbackSpeed) { player.setPlaybackSpeed(playbackSpeed) } LaunchedEffect(playbackSpeed) { player.setPlaybackSpeed(playbackSpeed) }
LaunchedEffect(state.currentPlayback?.subtitleDelay) { LaunchedEffect(player, state.currentPlayback?.subtitleDelay) {
(player as? MpvPlayer)?.subtitleDelay = player.applySubtitleDelay(state.currentPlayback?.subtitleDelay ?: Duration.ZERO)
state.currentPlayback?.subtitleDelay ?: Duration.ZERO
} }
val presentationState = rememberPresentationState(player, false) val presentationState = rememberPresentationState(player, false)
@ -784,8 +788,34 @@ fun PlaybackPageContent(
}, },
onPlaybackActionClick = onPlaybackActionClick, onPlaybackActionClick = onPlaybackActionClick,
onChangeSubtitleDelay = { viewModel.updateSubtitleDelay(it) }, onChangeSubtitleDelay = { viewModel.updateSubtitleDelay(it) },
enableSubtitleDelay = player is MpvPlayer, enableSubtitleDelay = subtitleDelaySupported(player, state.currentPlayback),
enableVideoScale = player !is MpvPlayer, enableVideoScale = player !is MpvPlayer,
) )
} }
} }
/**
* Whether the subtitle delay can be changed: always with MPV, and with ExoPlayer unless the server
* burns the selected subtitles into the video
*/
fun subtitleDelaySupported(
player: Player,
currentPlayback: CurrentPlayback?,
): Boolean =
when (player) {
is MpvPlayer -> {
true
}
is ExoPlayer -> {
val subtitleStream =
currentPlayback?.mediaSourceInfo?.mediaStreams?.firstOrNull {
it.type == MediaStreamType.SUBTITLE && it.index == currentPlayback.subtitleIndex
}
subtitleStream?.deliveryMethod != SubtitleDeliveryMethod.ENCODE
}
else -> {
false
}
}

View file

@ -1669,6 +1669,9 @@ class PlaybackViewModel
} }
} }
/**
* Load the enabled subtitle track's saved delay, or zero if no delay is saved.
*/
suspend fun loadSubtitleDelay() { suspend fun loadSubtitleDelay() {
state.value.currentPlayback?.let { state.value.currentPlayback?.let {
if (it.subtitleIndexEnabled) { if (it.subtitleIndexEnabled) {
@ -1681,7 +1684,9 @@ class PlaybackViewModel
it.subtitleIndex, it.subtitleIndex,
it.item.id, it.item.id,
) )
updateCurrentPlayback { it?.copy(subtitleDelay = result.delayMs.milliseconds) } }
updateCurrentPlayback {
it?.copy(subtitleDelay = result?.delayMs?.milliseconds ?: Duration.ZERO)
} }
} }
} }

View file

@ -0,0 +1,199 @@
package com.github.damontecres.wholphin
import androidx.datastore.core.DataStore
import com.github.damontecres.wholphin.data.CurrentUser
import com.github.damontecres.wholphin.data.RestoredSession
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.update
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.services.SetupNavigationManager
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.util.WholphinDispatchers
import com.github.damontecres.wholphin.util.configure
import com.github.damontecres.wholphin.util.reset
import io.mockk.*
import java.util.UUID
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import org.justinvisser.wolfskin.tv.TrailerReturnClock
import org.justinvisser.wolfskin.tv.TrailerReturnState
/** Only a valid trailer handoff continues an auto-off live identity; cold and locked entries stay gated. */
@OptIn(ExperimentalCoroutinesApi::class)
class MainActivityResumeTest {
private val dispatcher = StandardTestDispatcher()
private val repository = mockk<ServerRepository>()
private val setup = mockk<SetupNavigationManager>(relaxed = true)
private val navigation = NavigationManager()
private val preferences = mockk<DataStore<AppPreferences>>()
private val server = JellyfinServer(UUID.randomUUID(), "Household", "https://jellyfin.test", null)
private val current = CurrentUser(server,
JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = "test-token"))
private val automatic = MutableStateFlow(AppPreferences.getDefaultInstance().update { signInAutomatically = false })
private var elapsed = 1_000L
private var boot = 7
private val clock = mockk<TrailerReturnClock>().also {
every { it.readElapsedMillis() } answers { elapsed }
every { it.readBootCount() } answers { boot }
}
private val trailerReturn = TrailerReturnState(clock)
private val title = Destination.Wolfskin("title/${UUID.randomUUID()}")
private val model = MainActivityViewModel(mockk(), preferences, repository, setup, navigation,
mockk(relaxed = true), mockk(relaxed = true), mockk(relaxed = true), mockk(relaxed = true), trailerReturn)
@Before fun configure() {
WholphinDispatchers.configure(dispatcher)
every { preferences.data } returns automatic
every { repository.current } returns MutableStateFlow(current)
every { setup.backStack } returns mutableListOf(SetupDestination.AppContent(current))
coEvery { repository.getMostRecentServer() } returns RestoredSession.ServerOnly(current.server)
coEvery { repository.restoreSavedSession() } returns RestoredSession.ServerOnly(current.server)
}
@After fun reset() { WholphinDispatchers.reset() }
@Test fun ordinaryWarmReturnShowsThePickerWithAutomaticSignInOff() = runTest(dispatcher) {
val title = Destination.Wolfskin("title/${UUID.randomUUID()}")
navigation.backStack.add(title)
model.handleAppStart(resuming = true)
verify { setup.navigateTo(SetupDestination.UserList(current.server)) }
verify(exactly = 0) { setup.navigateTo(SetupDestination.AppContent(current)) }
coVerify(exactly = 0) { repository.restoreSavedSession() }
}
@Test fun trailerReturnKeepsTheSamePersonAndTitleButTheNextOrdinaryReturnShowsThePicker() = runTest(dispatcher) {
navigation.backStack.add(title)
trailerReturn.prepare(current, title)
model.handleAppStart(resuming = true)
verify(exactly = 1) { setup.navigateTo(SetupDestination.AppContent(current)) }
assertEquals(listOf(Destination.Home(), title), navigation.backStack.toList())
clearMocks(setup, answers = false)
model.handleAppStart(resuming = true)
verify { setup.navigateTo(SetupDestination.UserList(current.server)) }
verify(exactly = 0) { setup.navigateTo(SetupDestination.AppContent(current)) }
}
@Test fun onResumeCleanupCannotEraseTheHandoffCapturedBeforeAsynchronousStartup() = runTest(dispatcher) {
navigation.backStack.add(title)
trailerReturn.prepare(current, title)
model.appStart(null, resuming = true)
trailerReturn.finishHandoff()
advanceUntilIdle()
verify { setup.navigateTo(SetupDestination.AppContent(current)) }
coVerify(exactly = 0) { repository.getMostRecentServer() }
}
@Test fun expiredOrDifferentBootHandoffsStillShowThePicker() = runTest(dispatcher) {
navigation.backStack.add(title)
for ((age, returningBoot) in listOf(600_000L to 7, 1L to 8, 1L to -1)) {
elapsed = 1_000L
boot = 7
trailerReturn.prepare(current, title)
elapsed += age
boot = returningBoot
model.handleAppStart(resuming = true)
verify { setup.navigateTo(SetupDestination.UserList(current.server)) }
verify(exactly = 0) { setup.navigateTo(SetupDestination.AppContent(current)) }
}
}
@Test fun protectionAddedDuringAPendingHandoffStillRequiresUnlocking() = runTest(dispatcher) {
navigation.backStack.add(title)
for (user in listOf(current.user.copy(pin = "1234"), current.user.copy(requireLogin = true))) {
trailerReturn.prepare(current, title)
val protected = current.copy(user = user)
every { repository.current } returns MutableStateFlow(protected)
every { setup.backStack } returns mutableListOf(SetupDestination.AppContent(protected))
model.handleAppStart(resuming = true)
verify { setup.navigateTo(SetupDestination.UserList(current.server)) }
verify(exactly = 0) { setup.navigateTo(SetupDestination.AppContent(protected)) }
}
}
@Test fun anotherPersonOrTitleCannotReuseAPendingHandoff() = runTest(dispatcher) {
val other = current.copy(user = current.user.copy(id = UUID.randomUUID()))
navigation.backStack.add(title)
trailerReturn.prepare(other, title)
model.handleAppStart(resuming = true)
trailerReturn.prepare(current, Destination.Wolfskin("title/${UUID.randomUUID()}"))
model.handleAppStart(resuming = true)
verify(exactly = 2) { setup.navigateTo(SetupDestination.UserList(current.server)) }
verify(exactly = 0) { setup.navigateTo(SetupDestination.AppContent(current)) }
}
@Test fun ordinaryWarmReturnWithAutomaticSignInOnStillContinuesTheLivePerson() = runTest(dispatcher) {
automatic.value = automatic.value.update { signInAutomatically = true }
model.handleAppStart(resuming = true)
verify { setup.navigateTo(SetupDestination.AppContent(current)) }
coVerify(exactly = 0) { repository.restoreSavedSession() }
}
@Test fun coldLaunchCannotUseAPendingLiveHandoff() = runTest(dispatcher) {
navigation.backStack.add(title)
trailerReturn.prepare(current, title)
model.handleAppStart(resuming = false)
verify { setup.navigateTo(SetupDestination.UserList(current.server)) }
verify(exactly = 0) { setup.navigateTo(SetupDestination.AppContent(current)) }
}
@Test fun anExplicitPickerCannotBeDismissedByAPendingLiveHandoff() = runTest(dispatcher) {
navigation.backStack.add(title)
trailerReturn.prepare(current, title)
every { setup.backStack } returns mutableListOf(SetupDestination.UserList(current.server))
model.handleAppStart(resuming = true)
verify { setup.navigateTo(SetupDestination.UserList(current.server)) }
verify(exactly = 0) { setup.navigateTo(SetupDestination.AppContent(current)) }
}
@Test fun theHandoffDeadlineIsRecheckedAfterAsynchronousStartupHasWaited() = runTest(dispatcher) {
navigation.backStack.add(title)
trailerReturn.prepare(current, title)
model.appStart(null, resuming = true)
elapsed += 600_000L
advanceUntilIdle()
verify { setup.navigateTo(SetupDestination.UserList(current.server)) }
verify(exactly = 0) { setup.navigateTo(SetupDestination.AppContent(current)) }
}
@Test fun warmReturnCannotUnlockPinOrRequireLoginProfiles() = runTest(dispatcher) {
for (enabled in listOf(false, true)) {
for (user in listOf(current.user.copy(pin = "1234"), current.user.copy(requireLogin = true))) {
automatic.value = automatic.value.update { signInAutomatically = enabled }
val protected = current.copy(user = user)
every { repository.current } returns MutableStateFlow(protected)
every { setup.backStack } returns mutableListOf(SetupDestination.AppContent(protected))
model.handleAppStart(resuming = true)
verify { setup.navigateTo(SetupDestination.UserList(current.server)) }
verify(exactly = 0) { setup.navigateTo(SetupDestination.AppContent(protected)) }
}
}
}
@Test fun recreatedActivityWithoutALiveIdentityStillUsesTheColdStartPicker() = runTest(dispatcher) {
every { repository.current } returns MutableStateFlow<CurrentUser?>(null)
model.handleAppStart(resuming = true)
verify { setup.navigateTo(SetupDestination.UserList(current.server)) }
coVerify(exactly = 0) { repository.restoreSavedSession() }
}
@Test fun warmReturnDoesNotDismissAnExplicitPickerOrAnotherPersonsEntry() = runTest(dispatcher) {
val other = current.copy(user = current.user.copy(id = UUID.randomUUID()))
for (shown in listOf(SetupDestination.UserList(current.server), SetupDestination.AppContent(other))) {
every { setup.backStack } returns mutableListOf(shown)
model.handleAppStart(resuming = true)
verify { setup.navigateTo(SetupDestination.UserList(current.server)) }
verify(exactly = 0) { setup.navigateTo(SetupDestination.AppContent(current)) }
}
}
}

View file

@ -0,0 +1,376 @@
package com.github.damontecres.wholphin
import android.app.Application
import android.content.Intent
import android.content.pm.ActivityInfo
import android.content.pm.ResolveInfo
import android.net.Uri
import android.os.Bundle
import android.os.Looper
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.datastore.core.DataStore
import androidx.lifecycle.viewModelScope
import androidx.room.Room
import com.github.damontecres.wholphin.data.*
import com.github.damontecres.wholphin.data.model.*
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.update
import com.github.damontecres.wholphin.services.*
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.successResponse
import com.github.takahirom.roborazzi.captureRoboImage
import io.mockk.*
import java.io.File
import java.util.UUID
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
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.json.JSONObject
import org.junit.*
import org.junit.Assert.*
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
import org.justinvisser.wolfskin.design.*
import org.justinvisser.wolfskin.tv.*
import org.justinvisser.wolfskin.tv.read.LibraryReadFacade
import org.justinvisser.wolfskin.tv.update.FakeUpdateController
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** New repository and shell instances reproduce process death, not merely activity recreation. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class MainActivityTrailerRestoreTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private val server = JellyfinServer(UUID.randomUUID(), "Household", "https://jellyfin.test", "12.1.0")
private var user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = "saved-token")
private val users = mockk<UserApi>()
private val system = mockk<SystemApi>()
private val api = mockk<ApiClient>(relaxed = true)
private val recent = mockk<MostRecentServerProvider>()
private var recentValue: MostRecentServer = MostRecentServer.None
private val preferences = mockk<DataStore<AppPreferences>>()
private val automatic = MutableStateFlow(AppPreferences.getDefaultInstance().update { signInAutomatically = false })
private var elapsed = 1_000L
private val clock = mockk<TrailerReturnClock>().also {
every { it.readElapsedMillis() } answers { elapsed }
every { it.readBootCount() } returns 7
}
private val restoredReturn = TrailerReturnState(clock)
private val navigation = NavigationManager()
private val setup = mockk<SetupNavigationManager>(relaxed = true)
private val title = Destination.Wolfskin(titleRoute("Locke"))
private val database by lazy {
Room.inMemoryDatabaseBuilder(rule.activity, AppDatabase::class.java).allowMainThreadQueries().build()
}
private val dao by lazy { spyk(database.serverDao()) }
private val repository by lazy { ServerRepository(rule.activity, mockk(), dao, api, Dispatchers.Unconfined, recent) }
private val intents = mockk<IntentService>(relaxed = true)
private val model by lazy { MainActivityViewModel(rule.activity, preferences, repository, setup, navigation,
mockk(relaxed = true), mockk(relaxed = true), mockk(relaxed = true), intents, restoredReturn) }
@Volatile private var destination: SetupDestination = SetupDestination.Loading
private val conductarr = ConductarrSession(MemoryCacheStore())
private var shellModel: WolfskinShellViewModel? = null
private var previousFixtures = false
private var previousScreens = true
@Before fun savedIdentityButNoLiveSession() {
ConductarrPreference.initialize(rule.activity)
WolfskinScreenPreference.initialize(rule.activity)
previousFixtures = ConductarrPreference.fixtures
previousScreens = WolfskinScreenPreference.enabled
ConductarrPreference.updateFixtures(true)
WolfskinScreenPreference.enabled = true
dao.addOrUpdateServer(server)
user = dao.addOrUpdateUser(user)
recentValue = MostRecentServer.from(CurrentUser(server, user))
every { recent.get() } answers { recentValue }
every { api.userApi } returns users
every { api.systemApi } returns system
coEvery { users.getCurrentUser() } returns successResponse(UserDto(id = user.id, name = "Justin",
hasPassword = false, hasConfiguredPassword = false, hasConfiguredEasyPassword = false))
coEvery { system.getPublicSystemInfo() } returns successResponse(PublicSystemInfo(serverName = server.name, version = server.version))
every { preferences.data } returns automatic
every { setup.backStack } answers { mutableListOf(destination) }
every { setup.navigateTo(any()) } answers { destination = firstArg() }
navigation.backStack.add(title)
val handler = ResolveInfo().apply {
activityInfo = ActivityInfo().apply { packageName = "test.youtube"; name = "test.youtube.Player"; exported = true }
}
shadowOf(rule.activity.packageManager).addResolveInfoForIntent(
Intent(Intent.ACTION_VIEW, Uri.parse("vnd.youtube://ab_-defghij")), handler)
assertNull(repository.current.value)
}
@After fun closeSessions() {
val jobs = listOfNotNull(model.viewModelScope.coroutineContext[Job], shellModel?.viewModelScope?.coroutineContext?.get(Job))
jobs.forEach { it.cancel() }
shadowOf(Looper.getMainLooper()).idle()
runBlocking { jobs.forEach { it.join() } }
conductarr.close()
database.close()
ConductarrPreference.updateFixtures(previousFixtures)
WolfskinScreenPreference.enabled = previousScreens
}
private fun snapshot(): Bundle {
val oldProcess = TrailerReturnState(clock)
val alreadySignedIn = CurrentUser(server, user)
check(TitleTrailer(rule.activity).open("https://www.youtube.com/watch?v=ab_-defghij") {
oldProcess.prepare(alreadySignedIn, title)
})
return Bundle().also { oldProcess.save(it, alreadySignedIn, title) }
}
private fun restore(saved: Bundle? = snapshot(), resuming: Boolean = true) {
val result = model.viewModelScope.async(Dispatchers.Default) { model.handleAppStart(resuming = resuming, savedState = saved) }
rule.waitUntil(15_000) {
shadowOf(Looper.getMainLooper()).idle()
result.isCompleted
}
runBlocking { result.await() }
}
private fun assertGated() {
assertEquals(SetupDestination.UserList(server), destination)
assertNull(repository.current.value)
verify(exactly = 0) { api.update(any(), any()) }
}
private fun loadFreshShell(): ShellState {
val session = WolfskinSession()
val fresh = WolfskinShellViewModel(rule.activity, session, repository, navigation,
mockk(relaxed = true), mockk(relaxed = true), mockk<LibraryReadFacade>(relaxed = true),
WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), FakeUpdateController(), restoredReturn)
shellModel = fresh
runBlocking { fresh.load(server, requireNotNull(repository.current.value).user) }
return requireNotNull(session.state)
}
private fun render(shell: ShellState) {
shell.enterDestination(titleRoute("Locke"))
val fixture = TitleFixture(rule.activity.assets.open("wolfskin-titles.json").bufferedReader().use { JSONObject(it.readText()) })
val scene = TitleState(fixture.title("Locke", shell.person.key), shell.person) {}
rule.setContent { WolfskinTheme { TitleSurface(scene, shell) } }
}
private fun sameTitleAfterProcessDeath(automaticSignIn: Boolean, capture: String = automaticSignIn.toString()) {
automatic.value = automatic.value.update { signInAutomatically = automaticSignIn }
restore()
val current = requireNotNull(repository.current.value)
assertEquals(SetupDestination.AppContent(current), destination)
assertEquals(server.id, current.server.id)
assertEquals(user.id, current.user.id)
assertEquals(title, navigation.backStack.last())
val shell = loadFreshShell()
render(shell)
rule.onNodeWithTag("title-heading").assertIsDisplayed().assertTextEquals("Locke")
rule.onNodeWithText("Who’s watching?").assertDoesNotExist()
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "trailer-return").apply { mkdirs() }
rule.onRoot().captureRoboImage(File(directory, "process-restored-title-$capture.png"))
}
@Test fun processDeathReturnsToTheSamePersonAndTitleWithAutomaticSignInOff() = sameTitleAfterProcessDeath(false)
@Test fun processDeathDoesNotReopenTheFreshShellChooserWithAutomaticSignInOn() = sameTitleAfterProcessDeath(true)
@Test fun protectionAddedWhileTheProcessWasDeadStillRequiresUnlocking() {
val saved = snapshot()
for (protected in listOf(user.copy(pin = "URDL"), user.copy(requireLogin = true))) {
destination = SetupDestination.Loading
dao.updateUser(protected)
restore(saved)
assertGated()
}
}
@Test fun aDifferentMostRecentPersonCannotReplaceTheTrailerIdentityEvenWithAutomaticSignInOn() {
val saved = snapshot()
val other = dao.addOrUpdateUser(user.copy(rowId = 0, id = UUID.randomUUID(), name = "Mindel", accessToken = "another-token"))
recentValue = MostRecentServer.from(CurrentUser(server, other))
for (enabled in listOf(false, true)) {
destination = SetupDestination.Loading
automatic.value = automatic.value.update { signInAutomatically = enabled }
restore(saved)
assertGated()
}
}
@Test fun anExistingPickerCannotBeBypassedByATrailerSnapshot() {
destination = SetupDestination.UserList(server)
automatic.value = automatic.value.update { signInAutomatically = true }
restore()
assertGated()
}
@Test fun missingTokenCannotBeRecoveredFromTheSavedTask() {
val saved = snapshot()
dao.updateUser(user.copy(accessToken = null))
restore(saved)
assertGated()
}
@Test fun removedPeopleAreNotRecreatedByTheSavedTask() {
val saved = snapshot()
dao.deleteUser(server.id, user.id)
restore(saved)
assertGated()
}
@Test fun aFreshLaunchCannotUseAPreviousTrailerSnapshot() {
restore(snapshot(), resuming = false)
assertGated()
}
@Test fun aFreshColdLaunchWithoutSavedTaskStateKeepsThePicker() {
restore(saved = null)
assertGated()
}
@Test fun aReturnHoursLaterCannotRestoreThePerson() {
val saved = snapshot()
elapsed += 4L * 60L * 60L * 1000L
restore(saved)
assertGated()
}
@Test fun aDifferentRestoredDestinationCannotReuseTheIdentityRecord() {
val saved = snapshot()
navigation.backStack[navigation.backStack.lastIndex] = Destination.Wolfskin(titleRoute("another-title"))
restore(saved)
assertGated()
}
@Test fun automaticSignInWithoutATrailerRecordStillShowsTheColdChooser() {
automatic.value = automatic.value.update { signInAutomatically = true }
restore(saved = null)
render(loadFreshShell())
rule.onNodeWithText("Who’s watching?").assertIsDisplayed()
rule.onNodeWithTag("title-heading").assertDoesNotExist()
}
@Test fun expiryDuringProfileLookupCannotInstallTheSavedToken() {
val saved = snapshot()
every { dao.getServer(server.id) } answers {
elapsed += 600_000L
callOriginal()
}
restore(saved)
assertGated()
}
@Test fun restoringATrailerTaskDoesNotReplayItsOriginalLaunchIntent() {
coEvery { intents.parseIntent(any()) } returns IntentResult.Error("Old launch target expired")
model.appStart(Intent("old.launch.target"), resuming = true, savedState = snapshot())
rule.waitUntil(15_000) {
shadowOf(Looper.getMainLooper()).idle()
destination != SetupDestination.Loading
}
assertEquals(SetupDestination.AppContent(requireNotNull(repository.current.value)), destination)
assertEquals(user.id, repository.current.value!!.user.id)
assertEquals(title, navigation.backStack.last())
}
@Test fun aNewExplicitLaunchCancelsAnOlderPendingTaskRestore() {
val started = CompletableDeferred<Unit>()
val release = CompletableDeferred<Unit>()
var firstRead = true
every { preferences.data } returns kotlinx.coroutines.flow.flow {
if (firstRead) {
firstRead = false
started.complete(Unit)
release.await()
}
emit(automatic.value)
}
model.appStart(null, resuming = true, savedState = snapshot())
rule.waitUntil(15_000) {
shadowOf(Looper.getMainLooper()).idle()
started.isCompleted
}
model.appStart(null)
release.complete(Unit)
rule.waitUntil(15_000) {
shadowOf(Looper.getMainLooper()).idle()
destination != SetupDestination.Loading
}
assertGated()
}
private fun authenticateSavedPerson(person: JellyfinUser = user) {
recentValue = MostRecentServer.from(CurrentUser(server, person))
coEvery { recent.save(any()) } coAnswers {
recentValue = MostRecentServer.from(firstArg())
}
coEvery { users.getCurrentUser() } returns successResponse(UserDto(id = person.id, name = person.name,
hasPassword = false, hasConfiguredPassword = false, hasConfiguredEasyPassword = false))
runBlocking { check(repository.restoreLastSession() is RestoredSession.Success) }
clearMocks(api, answers = false, recordedCalls = true)
}
@Test fun aRecommendationsWorkerRestoredPersonReturnsToTheSameTitleWithoutSigningInAgain() {
authenticateSavedPerson()
sameTitleAfterProcessDeath(false, capture = "worker")
verify(exactly = 0) { api.update(any(), any()) }
}
@Test fun aWorkerRestoredPersonWithoutTrailerMetadataCannotSkipTheColdPicker() {
authenticateSavedPerson()
restore(saved = null)
assertEquals(SetupDestination.UserList(server), destination)
assertEquals(user.id, repository.current.value!!.user.id)
verify(exactly = 0) { api.update(any(), any()) }
}
@Test fun aDifferentCurrentWorkerIdentityCannotBeReplacedByTheCapturedPerson() {
val saved = snapshot()
val other = dao.addOrUpdateUser(user.copy(rowId = 0, id = UUID.randomUUID(), name = "Mindel", accessToken = "another-token"))
authenticateSavedPerson(other)
recentValue = MostRecentServer.from(CurrentUser(server, user))
restore(saved)
assertEquals(SetupDestination.UserList(server), destination)
assertEquals(other.id, repository.current.value!!.user.id)
verify(exactly = 0) { api.update(any(), any()) }
}
@Test fun aProtectedCurrentWorkerIdentityStillRequiresUnlocking() {
authenticateSavedPerson()
val saved = snapshot()
for ((pin, login) in listOf("URDL" to false, null to true)) {
destination = SetupDestination.Loading
runBlocking { repository.updateUserAuth(user, pin, login) }
restore(saved)
assertEquals(SetupDestination.UserList(server), destination)
assertTrue(repository.current.value!!.user.isProtected)
verify(exactly = 0) { api.update(any(), any()) }
}
}
@Test fun clearingTheSavedLoginInvalidatesAnOtherwiseEligibleTaskSnapshot() {
val saved = snapshot()
recentValue = MostRecentServer.Server(server.id, server.url)
restore(saved)
assertGated()
}
@Test fun aCachedPersonCannotDismissAnExplicitPickerWithSavedTrailerMetadata() {
authenticateSavedPerson()
val saved = snapshot()
destination = SetupDestination.UserList(server)
automatic.value = automatic.value.update { signInAutomatically = true }
restore(saved)
assertEquals(SetupDestination.UserList(server), destination)
assertEquals(user.id, repository.current.value!!.user.id)
verify(exactly = 0) { api.update(any(), any()) }
}
}

View file

@ -0,0 +1,180 @@
package com.github.damontecres.wholphin
import android.app.Application
import android.content.Intent
import android.content.pm.ActivityInfo
import android.content.pm.ResolveInfo
import android.net.Uri
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.lifecycle.Lifecycle
import com.github.damontecres.wholphin.data.CurrentUser
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.takahirom.roborazzi.captureRoboImage
import io.mockk.every
import io.mockk.mockk
import java.io.File
import java.util.UUID
import kotlinx.coroutines.flow.MutableStateFlow
import org.json.JSONObject
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.design.*
import org.justinvisser.wolfskin.tv.TitleFixture
import org.justinvisser.wolfskin.tv.TitleTrailer
import org.justinvisser.wolfskin.tv.TrailerReturnClock
import org.justinvisser.wolfskin.tv.TrailerReturnState
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** Only the first valid trailer return keeps an auto-off title visible; other returns re-enter setup. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class SignedInContentTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private val server = JellyfinServer(UUID.randomUUID(), "Household", "https://jellyfin.test", null)
private val user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = "test-token")
private val liveSession = MutableStateFlow<CurrentUser?>(CurrentUser(server, user))
private var elapsed = 1_000L
private var boot = 7
private val clock = mockk<TrailerReturnClock>().also {
every { it.readElapsedMillis() } answers { elapsed }
every { it.readBootCount() } answers { boot }
}
private val trailerReturn = TrailerReturnState(clock)
private lateinit var destination: Destination
private fun mount(person: JellyfinUser = user, automatic: Boolean = false) {
liveSession.value = CurrentUser(server, person)
val fixture = TitleFixture(rule.activity.assets.open("wolfskin-titles.json").bufferedReader().use { JSONObject(it.readText()) })
val data = fixture.title("Locke", "justin").copy(trailerUrl = "https://www.youtube.com/watch?v=ab_-defghij")
destination = Destination.Wolfskin("title/${data.item.id}")
val shell = ShellState(fixture.people, "justin").apply { pickerVisible = false; pickerLaunch = false; enterDestination("title/${data.item.id}") }
val title = TitleState(data, shell.person) { action ->
if (action is TitleAction.Trailer) assertTrue(TitleTrailer(rule.activity).open(action.url) {
trailerReturn.prepare(liveSession.value, destination)
})
}
rule.setContent {
WolfskinTheme {
SignedInContent(CurrentUser(server, person), liveSession, signInAutomatically = automatic,
trailerReturn, { destination }) {
TitleSurface(title, shell)
}
}
}
rule.onNodeWithTag("title-heading").assertTextEquals("Locke")
}
private fun stopAndReturn() {
rule.activityRule.scenario.moveToState(Lifecycle.State.CREATED)
// Startup or onResume may clear the marker before the returning composition catches up.
trailerReturn.finishHandoff()
rule.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
rule.waitForIdle()
}
@Test fun closingTrailerKeepsTheSameTitleMountedWithAutomaticSignInOff() {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("vnd.youtube://ab_-defghij"))
val handler = ResolveInfo().apply {
activityInfo = ActivityInfo().apply { packageName = "test.youtube"; name = "test.youtube.Player"; exported = true }
}
shadowOf(rule.activity.packageManager).addResolveInfoForIntent(intent, handler)
mount()
rule.onNodeWithTag("title-option-trailer").performClick()
assertEquals("test.youtube", shadowOf(rule.activity).nextStartedActivity.`package`)
stopAndReturn()
rule.onNodeWithTag("title-heading").assertIsDisplayed().assertTextEquals("Locke")
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "trailer-return").apply { mkdirs() }
rule.onRoot().captureRoboImage(File(directory, "returned-title.png"))
}
@Test fun aSecondOrdinaryReturnAfterTheTrailerHidesTheTitle() {
mount()
trailerReturn.prepare(liveSession.value, destination)
stopAndReturn()
rule.onNodeWithTag("title-heading").assertIsDisplayed()
stopAndReturn()
rule.onNodeWithTag("title-heading").assertDoesNotExist()
}
@Test fun handoffExpiringWhileAwayDoesNotRevealTheTitle() {
mount()
trailerReturn.prepare(liveSession.value, destination)
rule.activityRule.scenario.moveToState(Lifecycle.State.CREATED)
elapsed += 600_000L
rule.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
rule.waitForIdle()
rule.onNodeWithTag("title-heading").assertDoesNotExist()
}
@Test fun changedBootWhileAwayDoesNotRevealTheTitle() {
mount()
trailerReturn.prepare(liveSession.value, destination)
rule.activityRule.scenario.moveToState(Lifecycle.State.CREATED)
boot += 1
rule.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
rule.waitForIdle()
rule.onNodeWithTag("title-heading").assertDoesNotExist()
}
@Test fun requireLoginAddedDuringAHandoffKeepsTheTitleHidden() {
mount()
trailerReturn.prepare(liveSession.value, destination)
rule.activityRule.scenario.moveToState(Lifecycle.State.CREATED)
liveSession.value = CurrentUser(server, user.copy(requireLogin = true))
rule.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
rule.waitForIdle()
rule.onNodeWithTag("title-heading").assertDoesNotExist()
}
@Test fun ordinaryWarmReturnWithAutomaticSignInOnKeepsTheTitleVisible() {
mount(automatic = true)
stopAndReturn()
rule.onNodeWithTag("title-heading").assertIsDisplayed()
}
@Test fun ordinaryWarmReturnHidesTheTitleWithAutomaticSignInOff() {
mount()
stopAndReturn()
rule.onNodeWithTag("title-heading").assertDoesNotExist()
}
@Test fun stoppedPinProtectedTitleRemainsHiddenUntilThePickerUnlocksIt() {
mount(user.copy(pin = "1234"))
stopAndReturn()
rule.onNodeWithTag("title-heading").assertDoesNotExist()
}
@Test fun stoppedRequireLoginTitleRemainsHiddenUntilAuthentication() {
mount(user.copy(requireLogin = true))
stopAndReturn()
rule.onNodeWithTag("title-heading").assertDoesNotExist()
}
@Test fun protectionAddedAfterOpeningTheTitleIsReadAtStopNotFromTheOldEntry() {
mount()
trailerReturn.prepare(liveSession.value, destination)
liveSession.value = CurrentUser(server, user.copy(pin = "1234"))
stopAndReturn()
rule.onNodeWithTag("title-heading").assertDoesNotExist()
}
@Test fun profileProtectedWhileAwayDoesNotReappearOnReturn() {
mount()
trailerReturn.prepare(liveSession.value, destination)
rule.activityRule.scenario.moveToState(Lifecycle.State.CREATED)
liveSession.value = CurrentUser(server, user.copy(pin = "1234"))
rule.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
rule.waitForIdle()
rule.onNodeWithTag("title-heading").assertDoesNotExist()
}
}

View file

@ -72,6 +72,7 @@ class TestMainActivityViewModel {
backdropService, backdropService,
appUpgradeHandler, appUpgradeHandler,
intentService, intentService,
trailerReturn = mockk(relaxed = true),
) )
private val serverId = UUID.randomUUID() private val serverId = UUID.randomUUID()
@ -88,6 +89,7 @@ class TestMainActivityViewModel {
@Before @Before
fun setUp() { fun setUp() {
WholphinDispatchers.configure(testDispatcher) WholphinDispatchers.configure(testDispatcher)
every { setupNavigationManager.backStack } returns mutableListOf(SetupDestination.Loading)
every { appUpgradeHandler.needUpgrade() } returns false every { appUpgradeHandler.needUpgrade() } returns false
every { appUpgradeHandler.copySubfont(any()) } returns Unit every { appUpgradeHandler.copySubfont(any()) } returns Unit
mockkStatic(Toast::class) mockkStatic(Toast::class)
@ -167,14 +169,22 @@ class TestMainActivityViewModel {
@Test @Test
fun savedColdLaunchNavigatesBeforeSessionRevalidationCompletes() = runTest(testDispatcher) { fun savedColdLaunchNavigatesBeforeSessionRevalidationCompletes() = runTest(testDispatcher) {
setupPreferences { signInAutomatically = true } setupPreferences { signInAutomatically = true }
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null) val canonical = MutableStateFlow<CurrentUser?>(null)
coEvery { serverRepository.restoreSavedSession() } returns RestoredSession.Success(currentUser) every { serverRepository.current } returns canonical
coEvery { serverRepository.restoreSavedSession() } coAnswers {
canonical.value = currentUser
RestoredSession.Success(currentUser)
}
val release = CompletableDeferred<Unit>() val release = CompletableDeferred<Unit>()
coEvery { serverRepository.revalidateSession(currentUser) } coAnswers { release.await() } val validationStarted = CompletableDeferred<Unit>()
coEvery { serverRepository.revalidateSession(currentUser) } coAnswers {
validationStarted.complete(Unit)
release.await()
}
viewModel.handleAppStart() viewModel.handleAppStart()
runCurrent() runCurrent()
verify { setupNavigationManager.navigateTo(SetupDestination.AppContent(currentUser)) } verify { setupNavigationManager.navigateTo(SetupDestination.AppContent(currentUser)) }
assertFalse(release.isCompleted) Assert.assertTrue(validationStarted.isCompleted)
release.complete(Unit) release.complete(Unit)
} }

View file

@ -0,0 +1,29 @@
package com.github.damontecres.wholphin.services
import com.github.damontecres.wholphin.data.model.HomeRowConfig
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
import com.github.damontecres.wholphin.data.model.mergeHomeWatchRows
import org.junit.Assert.*
import org.junit.Test
/** Legacy saved watch lanes retain the first lane's order and display options after merging. */
class HomeWatchRowsTest {
@Test fun splitAndAlreadyCombinedRowsBecomeOneWithoutChangingOtherPresets() {
val options = HomeRowViewOptions(heightDp = 148)
val other = HomeRowConfig.GetItems(name = "Saved shelf", filter = com.github.damontecres.wholphin.data.model.HomeGetItemsFilter())
for (watchRows in listOf(
listOf(HomeRowConfig.ContinueWatching(options), other, HomeRowConfig.NextUp()),
listOf(HomeRowConfig.NextUp(options), other, HomeRowConfig.ContinueWatching()),
listOf(HomeRowConfig.ContinueWatchingCombined(options), other, HomeRowConfig.NextUp()),
)) {
val result = mergeHomeWatchRows(watchRows)
assertEquals(listOf(HomeRowConfig.ContinueWatchingCombined(options), other), result)
assertEquals(result, mergeHomeWatchRows(result))
}
}
@Test fun settingsWithoutWatchRowsStayWithoutWatchRows() {
val rows = listOf(HomeRowConfig.GetItems(name = "Saved shelf", filter = com.github.damontecres.wholphin.data.model.HomeGetItemsFilter()))
assertEquals(rows, mergeHomeWatchRows(rows))
}
}

View file

@ -0,0 +1,186 @@
@file:OptIn(markerClass = [UnstableApi::class])
package com.github.damontecres.wholphin.services
import android.content.Context
import android.net.Uri
import androidx.annotation.OptIn
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.MimeTypes
import androidx.media3.common.Player
import androidx.media3.common.text.CueGroup
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.test.utils.FakeClock
import androidx.media3.test.utils.robolectric.CapturingRenderersFactory
import androidx.media3.test.utils.robolectric.TestPlayerRunHelper.play
import androidx.media3.test.utils.robolectric.TestPlayerRunHelper.run
import androidx.test.core.app.ApplicationProvider
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.io.File
import java.nio.ByteBuffer
import java.nio.ByteOrder
import kotlin.math.abs
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
/**
* Plays a short audio file with a sideloaded SRT through a real [ExoPlayer] and checks when the
* subtitles appear for a given delay, and that playback still ends when the media does.
*/
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE, sdk = [30])
class SubtitleDelayPlaybackTest {
@get:Rule val folder = TemporaryFolder()
private val context = ApplicationProvider.getApplicationContext<Context>()
private val clock = FakeClock(true)
private val shownCues = mutableListOf<Pair<String, Long>>()
private val playbackStates = mutableListOf<Int>()
private lateinit var player: ExoPlayer
@Before
fun setUp() {
player =
ExoPlayer
.Builder(context, SubtitleDelayRenderersFactory(CapturingRenderersFactory(context, clock)))
.setMediaSourceFactory(DefaultMediaSourceFactory(context))
.setClock(clock)
.build()
player.addListener(
object : Player.Listener {
override fun onCues(cueGroup: CueGroup) {
cueGroup.cues.firstOrNull()?.let { shownCues.add(it.text.toString() to player.currentPosition) }
}
override fun onPlaybackStateChanged(playbackState: Int) {
playbackStates.add(playbackState)
}
},
)
player.setMediaItem(mediaItem())
player.prepare()
// Subtitles never hold up playback, so let the sideloaded file load before playing
run(player).untilState(Player.STATE_READY)
run(player).untilLoadingIs(false)
assertTrue(player.currentTracks.isTypeSelected(C.TRACK_TYPE_TEXT))
}
@After
fun tearDown() {
player.release()
}
@Test
fun positiveDelayShowsSubtitlesLaterAndPlaybackEndsOnTime() {
player.applySubtitleDelay(2.seconds)
val playStartMs = clock.elapsedRealtime()
play(player).untilState(Player.STATE_ENDED)
assertShownAt("First" to 3.seconds)
val playedMs = clock.elapsedRealtime() - playStartMs
assertTrue(
"ended after $playedMs ms of playback, expected $DURATION",
abs(playedMs - DURATION.inWholeMilliseconds) <= TOLERANCE_MS,
)
}
@Test
fun negativeDelayShowsSubtitlesEarlier() {
player.applySubtitleDelay((-500).milliseconds)
play(player).untilState(Player.STATE_ENDED)
assertShownAt("First" to 500.milliseconds, "Last" to 2.seconds)
}
@Test
fun changingTheDelayWhilePlayingRetimesWithoutRebuffering() {
play(player).untilPosition(0, 500)
player.applySubtitleDelay(1.seconds)
play(player).untilState(Player.STATE_ENDED)
assertShownAt("First" to 2.seconds, "Last" to 3_500.milliseconds)
assertEquals(listOf(Player.STATE_BUFFERING, Player.STATE_READY, Player.STATE_ENDED), playbackStates)
}
private fun assertShownAt(vararg expected: Pair<String, Duration>) {
assertEquals(expected.map { it.first }, shownCues.map { it.first })
expected.zip(shownCues).forEach { (want, got) ->
assertTrue(
"${want.first} shown at ${got.second} ms, expected ${want.second}",
abs(got.second - want.second.inWholeMilliseconds) <= TOLERANCE_MS,
)
}
}
private fun mediaItem(): MediaItem {
val audio = folder.newFile("silence.wav").apply { writeBytes(silentWav()) }
val subtitles = folder.newFile("subtitles.srt").apply { writeText(SRT) }
return MediaItem
.Builder()
.setUri(Uri.fromFile(audio))
.setSubtitleConfigurations(
listOf(
MediaItem.SubtitleConfiguration
.Builder(Uri.fromFile(subtitles))
.setMimeType(MimeTypes.APPLICATION_SUBRIP)
.setLanguage("en")
.setSelectionFlags(C.SELECTION_FLAG_DEFAULT)
.build(),
),
).build()
}
/** A mono 16-bit PCM WAV file of silence lasting [DURATION] */
private fun silentWav(): ByteArray {
val dataSize = (SAMPLE_RATE * DURATION.inWholeMilliseconds / 1000 * 2).toInt()
return ByteBuffer
.allocate(44 + dataSize)
.order(ByteOrder.LITTLE_ENDIAN)
.put("RIFF".toByteArray())
.putInt(36 + dataSize)
.put("WAVEfmt ".toByteArray())
.putInt(16)
.putShort(1)
.putShort(1)
.putInt(SAMPLE_RATE)
.putInt(SAMPLE_RATE * 2)
.putShort(2)
.putShort(16)
.put("data".toByteArray())
.putInt(dataSize)
.array()
}
private companion object {
const val SAMPLE_RATE = 8_000
const val TOLERANCE_MS = 100L
val DURATION = 4.seconds
val SRT =
"""
1
00:00:01,000 --> 00:00:02,000
First
2
00:00:02,500 --> 00:00:03,500
Last
""".trimIndent()
}
}

View file

@ -0,0 +1,165 @@
@file:OptIn(markerClass = [UnstableApi::class])
package com.github.damontecres.wholphin.services
import android.os.Handler
import android.os.Looper
import androidx.annotation.OptIn
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.Renderer
import androidx.media3.exoplayer.RendererConfiguration
import androidx.media3.exoplayer.RenderersFactory
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.SinglePeriodTimeline
import io.github.peerless2012.ass.media.AssHandler
import io.github.peerless2012.ass.media.factory.AssRenderersFactory
import io.github.peerless2012.ass.media.render.AssRenderer
import io.github.peerless2012.ass.media.type.AssRenderType
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE, sdk = [30])
class SubtitleDelayRendererTest {
private val delegate = mockk<Renderer>(relaxed = true)
private val renderer = SubtitleDelayRenderer(delegate)
private val timeline = SinglePeriodTimeline(DURATION_US, true, false, false, null, MediaItem.EMPTY)
private val mediaPeriodId = MediaSource.MediaPeriodId(timeline.getUidOfPeriod(0))
@Test
fun shiftsEveryPositionByTheDelay() {
for (delayUs in listOf(2_000_000L, -500_000L)) {
val delegate = mockk<Renderer>(relaxed = true)
val renderer = SubtitleDelayRenderer(delegate)
renderer.handleMessage(SubtitleDelayRenderer.MSG_SET_SUBTITLE_DELAY, delayUs)
val positionUs = OFFSET_US + 5_000_000L
val shiftedUs = positionUs - delayUs
enable(renderer, positionUs)
renderer.render(positionUs, 7L)
renderer.resetPosition(positionUs, true)
renderer.getDurationToProgressUs(positionUs, 7L)
renderer.supportsResetPositionWithoutKeyFrameReset(positionUs)
verify {
delegate.enable(any(), any(), any(), shiftedUs, false, true, OFFSET_US, OFFSET_US, mediaPeriodId)
delegate.render(shiftedUs, 7L)
delegate.resetPosition(shiftedUs, true)
delegate.getDurationToProgressUs(shiftedUs, 7L)
delegate.supportsResetPositionWithoutKeyFrameReset(shiftedUs)
}
}
}
@Test
fun delayMessageChangesTheShiftAndOtherMessagesPassThrough() {
renderer.render(10_000_000L, 0L)
renderer.handleMessage(SubtitleDelayRenderer.MSG_SET_SUBTITLE_DELAY, 250_000L)
renderer.render(10_000_000L, 0L)
renderer.handleMessage(Renderer.MSG_SET_VOLUME, 0.5f)
verify {
delegate.render(10_000_000L, 0L)
delegate.render(9_750_000L, 0L)
delegate.handleMessage(Renderer.MSG_SET_VOLUME, 0.5f)
}
verify(exactly = 0) { delegate.handleMessage(SubtitleDelayRenderer.MSG_SET_SUBTITLE_DELAY, any()) }
}
@Test
fun endsAtThePeriodEndWithAPositiveDelay() {
every { delegate.isEnded } returns false
enable(renderer, OFFSET_US)
renderer.setTimeline(timeline)
renderer.handleMessage(SubtitleDelayRenderer.MSG_SET_SUBTITLE_DELAY, 2_000_000L)
renderer.render(OFFSET_US + DURATION_US - 1, 0L)
assertFalse(renderer.isEnded)
renderer.render(OFFSET_US + DURATION_US, 0L)
assertTrue(renderer.isEnded)
}
@Test
fun otherwiseMirrorsTheDelegateAtThePeriodEnd() {
enable(renderer, OFFSET_US)
renderer.setTimeline(timeline)
for (delayUs in listOf(0L, -2_000_000L)) {
renderer.handleMessage(SubtitleDelayRenderer.MSG_SET_SUBTITLE_DELAY, delayUs)
renderer.render(OFFSET_US + DURATION_US, 0L)
for (delegateEnded in listOf(false, true)) {
every { delegate.isEnded } returns delegateEnded
assertEquals(delegateEnded, renderer.isEnded)
}
}
}
@Test
fun doesNotEndWithoutAKnownPeriodDuration() {
every { delegate.isEnded } returns false
val unknownDuration = SinglePeriodTimeline(C.TIME_UNSET, false, false, false, null, MediaItem.EMPTY)
enable(renderer, OFFSET_US, MediaSource.MediaPeriodId(unknownDuration.getUidOfPeriod(0)))
renderer.setTimeline(unknownDuration)
renderer.handleMessage(SubtitleDelayRenderer.MSG_SET_SUBTITLE_DELAY, 2_000_000L)
renderer.render(OFFSET_US + DURATION_US * 10, 0L)
assertFalse(renderer.isEnded)
}
@Test
fun factoryWrapsOnlySubtitleRenderers() {
val video = fakeRenderer(C.TRACK_TYPE_VIDEO)
val audio = fakeRenderer(C.TRACK_TYPE_AUDIO)
val text = fakeRenderer(C.TRACK_TYPE_TEXT)
val base =
mockk<RenderersFactory> {
every { createRenderers(any(), any(), any(), any(), any()) } returns arrayOf(video, audio, text)
}
val factory =
SubtitleDelayRenderersFactory(AssRenderersFactory(AssHandler(AssRenderType.OVERLAY_OPEN_GL), base))
val renderers =
factory.createRenderers(Handler(Looper.getMainLooper()), mockk(), mockk(), mockk(), mockk())
assertSame(video, renderers[0])
assertSame(audio, renderers[1])
assertSame(text, (renderers[2] as SubtitleDelayRenderer).delegate)
assertTrue((renderers.last() as SubtitleDelayRenderer).delegate is AssRenderer)
assertEquals(4, renderers.size)
}
private fun enable(
renderer: Renderer,
positionUs: Long,
periodId: MediaSource.MediaPeriodId = mediaPeriodId,
) = renderer.enable(
RendererConfiguration.DEFAULT,
arrayOf(),
mockk(),
positionUs,
false,
true,
OFFSET_US,
OFFSET_US,
periodId,
)
private fun fakeRenderer(trackType: Int) = mockk<Renderer>(relaxed = true) { every { this@mockk.trackType } returns trackType }
private companion object {
const val OFFSET_US = 1_000_000_000_000L
const val DURATION_US = 10_000_000L
}
}

View file

@ -3,16 +3,19 @@ package com.github.damontecres.wholphin.ui.playback
import android.content.Context import android.content.Context
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.TrackSelectionParameters
import com.github.damontecres.wholphin.data.ItemPlaybackDao import com.github.damontecres.wholphin.data.ItemPlaybackDao
import com.github.damontecres.wholphin.data.ItemPlaybackRepository import com.github.damontecres.wholphin.data.ItemPlaybackRepository
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.ItemTrackModification
import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.data.model.Playlist import com.github.damontecres.wholphin.data.model.Playlist
import com.github.damontecres.wholphin.data.model.PlaylistItem import com.github.damontecres.wholphin.data.model.PlaylistItem
import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType
import com.github.damontecres.wholphin.data.model.ServerUserConfig import com.github.damontecres.wholphin.data.model.ServerUserConfig
import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.PlaybackPreferences import com.github.damontecres.wholphin.preferences.PlaybackPreferences
import com.github.damontecres.wholphin.preferences.ShowNextUpWhen import com.github.damontecres.wholphin.preferences.ShowNextUpWhen
@ -82,6 +85,7 @@ import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@ -292,6 +296,76 @@ class PlaybackViewModelTests {
return viewModel return viewModel
} }
@Test
fun `Subtitle track switches load saved delays or zero and ignore disabled subtitles`() =
runTest(testDispatcher) {
setupPreferences { cinemaMode = false }
val tracks =
TestTracks
.Builder()
.addVideo()
.addAudio()
.addSubtitle(2)
val source = tracks.buildMediaSourceInfo()
val subtitles = source.mediaStreams!!.filter { it.type == MediaStreamType.SUBTITLE }
val trackA = subtitles[0].index
val trackB = subtitles[1].index
coEvery { mockUserLibraryApi.getItem(movie.id) } returns successResponse(movie)
coEvery { mockMediaInfoApi.getPostedPlaybackInfo(any(), any()) } returns
successResponse(
PlaybackInfoResponse(
mediaSources = listOf(source),
playSessionId = playSessionId,
),
)
coEvery { mockStreamChoiceService.chooseSource(any(), any()) } returns source
coEvery {
mockStreamChoiceService.chooseAudioStream(any(), any(), any(), any(), any())
} returns
StreamChoiceResult(
source.mediaStreams!!.first { it.type == MediaStreamType.AUDIO },
StreamChoiceReason.Unknown,
)
coEvery {
mockStreamChoiceService.chooseSubtitleStream(any(), any(), any(), any(), any(), any())
} returns StreamChoiceResult(subtitles[0], StreamChoiceReason.Unknown)
coEvery {
mockStreamChoiceService.resolveSubtitleIndex(any(), any(), any(), any(), any())
} answers { arg<Int>(3) }
every { mockPlayer.currentTracks } returns tracks.buildForExoPlayer().toTracks()
every { mockPlayer.trackSelectionParameters } returns TrackSelectionParameters.DEFAULT_WITHOUT_CONTEXT
coEvery { mockItemPlaybackRepository.getTrackModifications(movie.id, trackA) } returns
ItemTrackModification(user.rowId, movie.id, trackA, 2000L)
val cases =
listOf(
Triple(trackB, null, Duration.ZERO),
Triple(trackB, -750L, (-750).milliseconds),
Triple(TrackIndex.DISABLED, null, 2.seconds),
)
cases.forEach { (targetIndex, savedDelayMs, expectedDelay) ->
coEvery { mockItemPlaybackRepository.getTrackModifications(movie.id, trackB) } returns
savedDelayMs?.let {
ItemTrackModification(user.rowId, movie.id, trackB, it)
}
val viewModel = createViewModel(Destination.Playback(movie.id, 0L))
val initialPlayback = viewModel.state.value.currentPlayback
Assert.assertEquals(trackA, initialPlayback?.subtitleIndex)
Assert.assertEquals(2.seconds, initialPlayback?.subtitleDelay)
viewModel.changeSubtitleStream(targetIndex).join()
testScheduler.advanceUntilIdle()
val playback = viewModel.state.value.currentPlayback
Assert.assertEquals(targetIndex, playback?.subtitleIndex)
Assert.assertEquals(expectedDelay, playback?.subtitleDelay)
}
coVerify(exactly = 0) {
mockItemPlaybackRepository.getTrackModifications(movie.id, TrackIndex.DISABLED)
}
verify(exactly = cases.size) { mockPlayer.setMediaItem(any(), any<Long>()) }
}
@Test @Test
fun `Direct play includes the opened live stream ID`() = fun `Direct play includes the opened live stream ID`() =
runTest(testDispatcher) { runTest(testDispatcher) {

View file

@ -0,0 +1,146 @@
package com.github.damontecres.wholphin.ui.playback
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performSemanticsAction
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import androidx.test.core.app.ApplicationProvider
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.mpv.MpvPlayer
import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import io.mockk.mockk
import kotlinx.serialization.json.Json
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.PlayMethod
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.nio.file.Paths
import kotlin.io.path.readText
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
/**
* The subtitle delay control shows for every player that can move the selected subtitles, and only
* for those
*/
@Config(sdk = [34])
@RunWith(RobolectricTestRunner::class)
class SubtitleDelayControlTest {
@get:Rule val rule = createComposeRule()
private val subtitleDelay = ApplicationProvider.getApplicationContext<Context>().getString(R.string.subtitle_delay)
@Test
fun supportedUnlessTheSubtitlesAreBurnedIn() {
val exoPlayer = mockk<ExoPlayer>()
val cases =
listOf(
Triple(mockk<MpvPlayer>(), playback(EMBEDDED_INDEX), true),
Triple(exoPlayer, playback(EMBEDDED_INDEX), true),
Triple(exoPlayer, playback(EXTERNAL_INDEX), true),
Triple(exoPlayer, playback(EMBEDDED_INDEX, burnedIn = true), false),
Triple(exoPlayer, playback(TrackIndex.DISABLED, burnedIn = true), true),
Triple(mockk<Player>(), playback(EMBEDDED_INDEX), false),
)
for ((player, playback, expected) in cases) {
assertEquals(
"${player.javaClass.simpleName} ${playback.subtitleIndex}",
expected,
subtitleDelaySupported(player, playback),
)
}
}
@Test
fun settingsListTheSubtitleDelayOnlyWhenEnabled() {
val enabled = mutableStateOf(true)
rule.setContent { WholphinTheme { Dialog(PlaybackDialogType.SETTINGS, enabled.value) {} } }
rule.onNodeWithText(subtitleDelay).assertExists()
enabled.value = false
rule.onNodeWithText(subtitleDelay).assertDoesNotExist()
}
@Test
fun delayButtonsReportTheChange() {
val changes = mutableListOf<Duration>()
rule.setContent { WholphinTheme { Dialog(PlaybackDialogType.SUBTITLE_DELAY, true) { changes.add(it) } } }
rule.onNodeWithText("+250ms").performSemanticsAction(SemanticsActions.OnClick)
assertEquals(listOf(250.milliseconds), changes)
}
@Composable
private fun Dialog(
type: PlaybackDialogType,
enableSubtitleDelay: Boolean,
onChangeSubtitleDelay: (Duration) -> Unit,
) = PlaybackDialog(
enableSubtitleDelay = enableSubtitleDelay,
enableVideoScale = true,
type = type,
settings =
PlaybackSettings(
showDebugInfo = false,
audioIndex = null,
audioStreams = listOf(),
subtitleIndex = null,
subtitleStreams = listOf(),
playbackSpeed = 1f,
contentScale = ContentScale.Fit,
subtitleDelay = Duration.ZERO,
hasSubtitleDownloadPermission = false,
playbackSpeedEnabled = true,
maxBitrate = 0L,
),
onDismissRequest = {},
onControllerInteraction = {},
onClickPlaybackDialogType = {},
onPlaybackActionClick = {},
onChangeSubtitleDelay = onChangeSubtitleDelay,
)
/**
* A playback of `external_subs.json`, a PlaybackInfo response whose subtitle streams carry the
* server's delivery method, with [subtitleIndex] selected; [burnedIn] makes the server burn the
* embedded subtitles into the video
*/
private fun playback(
subtitleIndex: Int,
burnedIn: Boolean = false,
): CurrentPlayback {
val resource = javaClass.classLoader!!.getResource("external_subs.json")
var json = Paths.get(resource.toURI()).readText()
if (burnedIn) {
json = json.replace("\"DeliveryMethod\": \"Embed\"", "\"DeliveryMethod\": \"Encode\"")
}
return CurrentPlayback(
item = mockk(),
tracks = listOf(),
backend = PlayerBackend.EXO_PLAYER,
playMethod = PlayMethod.DIRECT_PLAY,
playSessionId = null,
liveStreamId = null,
mediaSourceInfo = Json.decodeFromString<MediaSourceInfo>(json),
subtitleIndex = subtitleIndex,
)
}
private companion object {
const val EXTERNAL_INDEX = 0
const val EMBEDDED_INDEX = 5
}
}

View file

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

View file

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

View file

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

View file

@ -57,7 +57,7 @@ class ConductarrHomeSurfaceTest {
coEvery { home.readLibrary(user.id) } returns HomeLibrary() coEvery { home.readLibrary(user.id) } returns HomeLibrary()
val cache = mockk<HomeLibraryCache>(relaxed = true) val cache = mockk<HomeLibraryCache>(relaxed = true)
every { cache.read(any()) } returns null every { cache.read(any()) } returns null
val model = WolfskinHomeViewModel(rule.activity, home, cache, NavigationManager(), WolfskinSeeAllLists(), session) val model = WolfskinHomeViewModel(rule.activity, home, cache, NavigationManager(), WolfskinSeeAllLists(), session, WolfskinSession())
try { try {
val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", true)), "shared").apply { pickerVisible = false; pickerLaunch = false } val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", true)), "shared").apply { pickerVisible = false; pickerLaunch = false }
rule.setContent { WolfskinTheme { rule.setContent { WolfskinTheme {

View file

@ -70,7 +70,7 @@ class ConductarrTitlePagesTest {
assertEquals(TitleConnection.SignedOut, revoked.connection) assertEquals(TitleConnection.SignedOut, revoked.connection)
assertTrue(revoked.item.ratings.isEmpty()) assertTrue(revoked.item.ratings.isEmpty())
assertTrue(revoked.playTarget!!.ratings.isEmpty()) assertTrue(revoked.playTarget!!.ratings.isEmpty())
assertFalse(TitleState(revoked, ShellPerson("shared", "Shared", "S", true)) {}.options.any { it.id == "add" }) assertFalse(TitleState(revoked, ShellPerson("shared", "Shared", "S", true)) {}.menuOptions.any { it.id == "add" })
} }
} }
} }
@ -85,4 +85,36 @@ class ConductarrTitlePagesTest {
assertTrue(emptyList<LibraryRating>().withEpisode(8.0, null).isEmpty()) assertTrue(emptyList<LibraryRating>().withEpisode(8.0, null).isEmpty())
assertTrue(emptyList<LibraryRating>().withEpisode(8.0, 0).isEmpty()) assertTrue(emptyList<LibraryRating>().withEpisode(8.0, 0).isEmpty())
} }
@Test fun requestIdentityUsesTheTitleRootNotTheEpisodeBeingWatched() {
val viewer = ShellPerson("shared", "Shared", "S", true)
val episode = TitleItem("episode-id", "Episode", "episode", tmdbId = 999)
val series = TitlePageData(TitleItem("series-id", "Series", "tv", held = true, tmdbId = 44),
watchTarget = episode, seasonNumbers = listOf(0, 1, 2))
val request = requireNotNull(series.requestTitle(viewer))
assertEquals(44L, request.tmdb)
assertEquals("tv", request.type)
assertEquals(listOf(1, 2), request.seasons)
assertEquals(setOf("shared"), request.heldBy)
val catalogue = TitlePageData(TitleItem("movie:42", "Film", "movie")).requestTitle(viewer)
assertEquals(42L, catalogue?.tmdb)
assertEquals("movie", catalogue?.type)
}
@Test fun incompleteOrNonCatalogueRootsCannotBecomeRequestsForAnotherTitle() {
val viewer = ShellPerson("shared", "Shared", "S", true)
val invalid = listOf(
TitleItem("fixture-slug", "Unknown", "movie"),
TitleItem("fixture-slug", "Unknown", "movie", tmdbId = 0),
TitleItem("fixture-slug", "Unknown", "tv", tmdbId = -1),
TitleItem("tv:42", "Wrong media", "movie"),
TitleItem("42", "Not a catalogue route", "tv"),
TitleItem("movie:0", "Zero", "movie"),
TitleItem("tv:-42", "Negative", "tv"),
TitleItem("movie:42:1", "Malformed", "movie"),
TitleItem("episode-id", "Orphan episode", "episode", tmdbId = 999),
TitleItem("person:216", "Person", "person", tmdbId = 216),
)
invalid.forEach { item -> assertNull(item.toString(), TitlePageData(item).requestTitle(viewer)) }
}
} }

View file

@ -22,6 +22,7 @@ import com.github.takahirom.roborazzi.captureRoboImage
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.mockk import io.mockk.mockk
import java.io.File import java.io.File
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.* import kotlinx.serialization.json.*
@ -49,7 +50,8 @@ class ConductarrTitleSurfaceTest {
runBlocking { session.authenticate() } runBlocking { session.authenticate() }
val facade = mockk<TitleReadFacade>() val facade = mockk<TitleReadFacade>()
coEvery { facade.findHeld(any(), any()) } returns null coEvery { facade.findHeld(any(), any()) } returns null
val viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), mockk<NavigationManager>(relaxed = true), session, mockk<ServerRepository>(relaxed = true)) val viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), mockk<NavigationManager>(relaxed = true), session, mockk<ServerRepository>(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true))
val revokedLookup = CompletableDeferred<java.util.UUID?>()
try { try {
val route = titleRoute("movie:42") val route = titleRoute("movie:42")
val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", true)), "shared").apply { val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", true)), "shared").apply {
@ -76,6 +78,9 @@ class ConductarrTitleSurfaceTest {
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))).apply { mkdirs() } val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))).apply { mkdirs() }
rule.onRoot().captureRoboImage(File(directory, "conductarr-title-real.png").absolutePath) rule.onRoot().captureRoboImage(File(directory, "conductarr-title-real.png").absolutePath)
rule.onRoot().performKeyInput { pressKey(Key.DirectionRight); pressKey(Key.DirectionCenter) } rule.onRoot().performKeyInput { pressKey(Key.DirectionRight); pressKey(Key.DirectionCenter) }
rule.mainClock.advanceTimeBy(300)
rule.onNodeWithText("Add for someone").assertIsDisplayed()
rule.onRoot().performKeyInput { pressKey(Key.DirectionCenter) }
rule.onNodeWithText("Request for Mindel").assertIsDisplayed() rule.onNodeWithText("Request for Mindel").assertIsDisplayed()
rule.onRoot().performKeyInput { pressKey(Key.DirectionDown); pressKey(Key.DirectionCenter) } rule.onRoot().performKeyInput { pressKey(Key.DirectionDown); pressKey(Key.DirectionCenter) }
rule.waitUntil(30_000) { rule.waitUntil(30_000) {
@ -90,15 +95,26 @@ class ConductarrTitleSurfaceTest {
assertFalse(body.containsKey("actor")) assertFalse(body.containsKey("actor"))
rule.onRoot().captureRoboImage(File(directory, "conductarr-title-receipt.png").absolutePath) rule.onRoot().captureRoboImage(File(directory, "conductarr-title-receipt.png").absolutePath)
rule.onRoot().performKeyInput { pressKey(Key.DirectionCenter) } rule.onRoot().performKeyInput { pressKey(Key.DirectionCenter) }
rule.mainClock.autoAdvance = false
coEvery { facade.findHeld(any(), any()) } coAnswers { revokedLookup.await() }
fake.ratingsStatus = 401 fake.ratingsStatus = 401
runBlocking { ConductarrRatings(session.identity.value!!.client).read(45, "movie", null, null) } runBlocking { ConductarrRatings(session.identity.value!!.client).read(45, "movie", null, null) }
rule.waitUntil(30_000) { rule.waitUntil(30_000) {
org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
rule.mainClock.advanceTimeByFrame()
viewModel.title?.data?.connection == TitleConnection.SignedOut && viewModel.title?.data?.status == TitleReadStatus.Unavailable viewModel.title?.data?.connection == TitleConnection.SignedOut && viewModel.title?.data?.status == TitleReadStatus.Unavailable
} }
repeat(12) { index ->
rule.mainClock.advanceTimeBy(16)
assertEquals("Revoking access must clear private facts without replaying the first-load skeleton",
TitleReadStatus.Unavailable, viewModel.title!!.data.status)
if (index == 0 || index == 11) rule.onRoot().captureRoboImage(File(directory, "conductarr-title-revoked-$index.png").absolutePath)
}
revokedLookup.complete(null)
assertTrue(viewModel.title!!.data.item.ratings.isEmpty()) assertTrue(viewModel.title!!.data.item.ratings.isEmpty())
assertFalse(viewModel.title!!.options.any { it.id in listOf("request", "other", "add") }) assertFalse(viewModel.title!!.options.any { it.id == "request" })
} finally { viewModel.viewModelScope.cancel() } assertFalse(viewModel.title!!.menuOptions.any { it.id == "add" })
} finally { viewModel.viewModelScope.cancel(); revokedLookup.complete(null) }
} }
} }
} }

View file

@ -0,0 +1,255 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.view.View
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.takahirom.roborazzi.captureRoboImage
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
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.TitleReadFacade
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import java.io.File
/** Refreshing a loaded Discover title must preserve its real header instead of rebuilding a skeleton. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class DiscoverRefreshMotionTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test fun loadedCatalogueRefreshKeepsReadyActionsOpaqueAndStationary() {
FakeConductarr().use { fake ->
fake.localArt = true
val stored = MemoryCacheStore()
val cache = object : org.justinvisser.wolfskin.conductarr.CacheStore by stored {
@Volatile var lost = false
override fun read(scope: String, key: String) = if (lost) null else stored.read(scope, key)
}
ConductarrSession(cache).use { session ->
session.select("server:shared", SHARED_TOKEN, fake.url)
runBlocking { session.authenticate() }
val facade = mockk<TitleReadFacade>()
coEvery { facade.findHeld(any(), any()) } returns null
val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), NavigationManager(), session, mockk(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true))
val person = ShellPerson("shared", "Shared", "S", false)
val route = titleRoute("tv:44")
val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; enterDestination("discover"); enterDestination(route) }
val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null)
val refreshLookup = CompletableDeferred<java.util.UUID?>()
try {
render(model, shell)
rule.runOnIdle { model.load(route, person, preferences, fixtures = false) }
rule.waitUntil(30_000) {
Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
model.title?.data?.status == TitleReadStatus.Ready && model.title?.data?.cast?.isNotEmpty() == true && model.title?.data?.related?.isNotEmpty() == true
}
rule.mainClock.advanceTimeBy(640)
val directory = File(System.getProperty("wolfskin.captureDirectory"), "discover-refresh").apply { mkdirs() }
val bounds = rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot
val pixels = IntArray(bounds.width.toInt() * bounds.height.toInt())
val baseline = brightness(File(directory, "loaded.png"), bounds, pixels)
cache.lost = true
coEvery { facade.findHeld(any(), any()) } coAnswers { refreshLookup.await() }
rule.runOnIdle {
model.load(route, person, preferences, fixtures = false, force = true)
assertEquals("An already-loaded catalogue title must never emit Loading on refresh", TitleReadStatus.Ready, model.title!!.data.status)
}
repeat(12) { index ->
rule.mainClock.advanceTimeBy(16)
assertEquals(TitleReadStatus.Ready, model.title!!.data.status)
assertFalse("A refresh cannot focus the root navigation", shell.navFocused)
assertEquals("Loaded actions cannot replay their rise", bounds, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot)
assertEquals("Loaded actions cannot replay their fade", baseline, brightness(File(directory, "refresh-${index.toString().padStart(2, '0')}.png"), bounds, pixels))
}
refreshLookup.complete(null)
rule.waitUntil(30_000) {
Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
model.title?.data?.watchReady == true
}
repeat(12) {
rule.mainClock.advanceTimeByFrame()
assertEquals(TitleReadStatus.Ready, model.title!!.data.status)
}
assertSameFrame(File(directory, "loaded.png"), File(directory, "refreshed.png"))
} finally {
model.viewModelScope.cancel()
refreshLookup.complete(null)
}
}
}
}
@Test fun loadedFixtureRefreshKeepsTheSameReadyHeaderAndCatalogueIdentity() {
ConductarrSession(MemoryCacheStore()).use { session ->
val model = WolfskinTitleViewModel(rule.activity, mockk(relaxed = true), TitleFixtureProvider(rule.activity),
NavigationManager(), session, mockk(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true))
val person = ShellPerson("justin", "Justin", "J", true)
val route = titleRoute("movie-the-amazing-spider-man-2012")
val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; enterDestination("discover"); enterDestination(route) }
val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null)
try {
render(model, shell)
rule.runOnIdle { model.load(route, person, preferences, fixtures = true) }
rule.waitUntil(30_000) {
Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
model.title?.data?.status == TitleReadStatus.Ready
}
rule.mainClock.advanceTimeBy(640)
val loaded = model.title!!
val request = requireNotNull(loaded.data.requestTitle(person))
assertEquals(1930L, request.tmdb)
assertEquals("movie", request.type)
val directory = File(System.getProperty("wolfskin.captureDirectory"), "discover-fixture-refresh").apply { mkdirs() }
val bounds = rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot
val pixels = IntArray(bounds.width.toInt() * bounds.height.toInt())
val baseline = brightness(File(directory, "loaded.png"), bounds, pixels)
val refreshedWatched = !loaded.data.watchTarget.watched
rule.activity.getSharedPreferences("wolfskin-title-fixture", android.content.Context.MODE_PRIVATE).edit()
.putBoolean("${person.key}:${loaded.data.watchTarget.id}:watched", refreshedWatched).commit()
rule.runOnIdle {
model.load(route, person, preferences, fixtures = true, force = true)
assertEquals("A fixture foreground refresh cannot reinstate Loading", TitleReadStatus.Ready, model.title!!.data.status)
}
repeat(12) { index ->
rule.mainClock.advanceTimeBy(16)
assertEquals(TitleReadStatus.Ready, model.title!!.data.status)
assertFalse(shell.navFocused)
assertEquals(bounds, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot)
assertEquals(baseline, brightness(File(directory, "refresh-${index.toString().padStart(2, '0')}.png"), bounds, pixels))
}
rule.waitUntil(30_000) {
Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
model.title?.data?.watchTarget?.watched == refreshedWatched
}
assertEquals(refreshedWatched, model.title!!.watched)
rule.mainClock.advanceTimeBy(640)
assertEquals(TitleReadStatus.Ready, model.title!!.data.status)
assertSameFrame(File(directory, "loaded.png"), File(directory, "refreshed.png"))
} finally { model.viewModelScope.cancel() }
}
}
@Test fun loadedHeldRefreshKeepsTheHeaderVisibleWhilePlaybackIsRevalidated() {
ConductarrSession(MemoryCacheStore()).use { session ->
val id = java.util.UUID.fromString("00000000-0000-0000-0000-000000000042")
val held = TitlePageData(TitleItem(id.toString(), "Held film", "movie", held = true))
val facade = mockk<TitleReadFacade>()
coEvery { facade.readSaved(any(), any()) } returns null
coEvery { facade.read(any(), any(), any()) } returns held
val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), NavigationManager(), session, mockk(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true))
val person = ShellPerson("shared", "Shared", "S", false)
val route = titleRoute(id.toString())
val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; enterDestination(route) }
val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null)
val refresh = CompletableDeferred<TitlePageData>()
try {
render(model, shell)
rule.runOnIdle { model.load(route, person, preferences, fixtures = false) }
rule.waitUntil(30_000) {
Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
model.title?.data?.status == TitleReadStatus.Ready
}
rule.mainClock.advanceTimeBy(640)
val directory = File(System.getProperty("wolfskin.captureDirectory"), "discover-held-refresh").apply { mkdirs() }
val bounds = rule.onNodeWithText("Held film").fetchSemanticsNode().boundsInRoot
val pixels = IntArray(bounds.width.toInt() * bounds.height.toInt())
val baseline = brightness(File(directory, "loaded.png"), bounds, pixels)
coEvery { facade.read(any(), any(), any()) } coAnswers { refresh.await() }
rule.runOnIdle {
model.load(route, person, preferences, fixtures = false, force = true)
assertEquals(TitleReadStatus.Ready, model.title!!.data.status)
assertFalse(model.title!!.data.watchReady)
}
repeat(12) { index ->
rule.mainClock.advanceTimeBy(16)
assertEquals(TitleReadStatus.Ready, model.title!!.data.status)
assertEquals(bounds, rule.onNodeWithText("Held film").fetchSemanticsNode().boundsInRoot)
assertEquals(baseline, brightness(File(directory, "refresh-${index.toString().padStart(2, '0')}.png"), bounds, pixels))
}
refresh.complete(held)
rule.waitUntil(30_000) {
Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
model.title?.data?.watchReady == true
}
assertEquals(TitleReadStatus.Ready, model.title!!.data.status)
assertTrue(model.title!!.options.any { it.id == "play" })
rule.mainClock.advanceTimeBy(640)
assertSameFrame(File(directory, "loaded.png"), File(directory, "refreshed.png"))
} finally { model.viewModelScope.cancel(); refresh.complete(held) }
}
}
private fun render(model: WolfskinTitleViewModel, shell: ShellState) {
rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
@Suppress("DEPRECATION")
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
rule.mainClock.autoAdvance = false
val art = Bitmap.createBitmap(8, 8, Bitmap.Config.ARGB_8888).apply { eraseColor(android.graphics.Color.DKGRAY) }.asImageBitmap()
rule.setContent { WolfskinTheme {
model.title?.let { state ->
val presented = remember(model) { booleanArrayOf(false) }
val status = state.data.status
SideEffect {
if (presented[0]) assertEquals("Every rendered refresh frame stays Ready", TitleReadStatus.Ready, status)
if (status == TitleReadStatus.Ready) presented[0] = true
}
val paths = listOfNotNull(state.data.item.poster, state.data.item.backdrop) +
state.data.cast.mapNotNull { it.portrait } +
(state.data.related + state.data.episodes).flatMap { listOfNotNull(it.poster, it.backdrop) }
CompositionLocalProvider(LocalTitleBitmaps provides paths.associateWith { art }) {
TitleSurface(state, shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight))
}
}
} }
}
private fun assertSameFrame(before: File, after: File) {
rule.onRoot().captureRoboImage(after.absolutePath)
val first = BitmapFactory.decodeFile(before.absolutePath)
val second = BitmapFactory.decodeFile(after.absolutePath)
try {
assertTrue("Same-data refresh must return the identical rendered frame", first.sameAs(second))
} finally { first.recycle(); second.recycle() }
}
private fun brightness(file: File, bounds: Rect, pixels: IntArray): Long {
rule.onRoot().captureRoboImage(file.absolutePath)
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
return try {
val width = bounds.width.toInt()
val height = bounds.height.toInt()
bitmap.getPixels(pixels, 0, width, bounds.left.toInt(), bounds.top.toInt(), width, height)
pixels.sumOf { (android.graphics.Color.red(it) + android.graphics.Color.green(it) + android.graphics.Color.blue(it)).toLong() }
} finally { bitmap.recycle() }
}
}

View file

@ -43,7 +43,7 @@ class DiscoverSkeletonKeysTest {
} }
press(Key.DirectionDown) press(Key.DirectionDown)
rule.runOnIdle { assertEquals(1, state.focusedRow); assertNull(state.cardKey) } rule.runOnIdle { assertEquals(1, state.focusedRow); assertNull(state.cardKey) }
press(Key.Back) press(Key.Escape)
rule.runOnIdle { assertTrue(shell.navFocused); assertFalse(shell.pickerVisible); assertEquals("discover", shell.route) } rule.runOnIdle { assertTrue(shell.navFocused); assertFalse(shell.pickerVisible); assertEquals("discover", shell.route) }
} }

View file

@ -36,6 +36,31 @@ import org.robolectric.annotation.GraphicsMode
class DiscoverTitleTimingTest { class DiscoverTitleTimingTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>() @get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test fun detailsPushKeepsTheOutgoingDiscoverHeroUntilNavigationReplacesIt() {
val shell = ShellState(listOf(ShellPerson("justin", "Justin", "J", true)), "justin").apply {
pickerVisible = false; pickerLaunch = false; enterDestination("discover")
}
var pushed = ""
lateinit var discover: DiscoverState
rule.setContent {
discover = rememberDiscoverState(shell, { pushed = it }, {}, fixtures = true)
WolfskinTheme { ScreenEntry(shell, "discover", true) {
ShellSurface(shell) { DiscoverScreen(discover, it) }
} }
}
rule.waitUntil(30_000) { discover.snapshot.status == DiscoverStatus.Ready }
rule.mainClock.advanceTimeBy(640)
rule.onNodeWithText("Details").assertIsDisplayed()
val directory = File(System.getProperty("wolfskin.captureDirectory"), "discover-entry").apply { mkdirs() }
rule.onRoot().captureRoboImage(File(directory, "hero-before-push.png").absolutePath)
rule.runOnIdle { discover.openTitle(requireNotNull(discover.hero)) }
rule.waitForIdle()
assertEquals(titleRoute("movie:1930"), pushed)
rule.onNodeWithText("Details").assertIsDisplayed()
rule.onNodeWithText("For someone else").assertDoesNotExist()
rule.onRoot().captureRoboImage(File(directory, "hero-after-push.png").absolutePath)
}
@Test fun discoverDetailsFillCastAndRelatedAfterAsyncReplyAndBackRestoresDiscover() { @Test fun discoverDetailsFillCastAndRelatedAfterAsyncReplyAndBackRestoresDiscover() {
FakeConductarr().use { fake -> FakeConductarr().use { fake ->
fake.localArt = true fake.localArt = true
@ -46,7 +71,7 @@ class DiscoverTitleTimingTest {
val facade = mockk<TitleReadFacade>() val facade = mockk<TitleReadFacade>()
coEvery { facade.findHeld(any(), any()) } returns null coEvery { facade.findHeld(any(), any()) } returns null
val navigation = NavigationManager().apply { replace(Destination.Wolfskin("discover")) } val navigation = NavigationManager().apply { replace(Destination.Wolfskin("discover")) }
val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), navigation, session, mockk(relaxed = true)) val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), navigation, session, mockk(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true))
val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", false)), "shared").apply { val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", false)), "shared").apply {
pickerVisible = false; pickerLaunch = false; enterDestination("discover") pickerVisible = false; pickerLaunch = false; enterDestination("discover")
} }
@ -84,7 +109,7 @@ class DiscoverTitleTimingTest {
assertEquals("related", model.title!!.row) assertEquals("related", model.title!!.row)
rule.onNodeWithText("More like this").assertIsDisplayed() rule.onNodeWithText("More like this").assertIsDisplayed()
rule.onRoot().captureRoboImage(File(captures, "discover-title-related.png").absolutePath) rule.onRoot().captureRoboImage(File(captures, "discover-title-related.png").absolutePath)
rule.onRoot().performKeyInput { pressKey(Key.Back) } rule.onRoot().performKeyInput { pressKey(Key.Escape) }
rule.waitForIdle() rule.waitForIdle()
assertNull(discover.title) assertNull(discover.title)
assertEquals("discover", shell.route) assertEquals("discover", shell.route)

View file

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

View file

@ -0,0 +1,192 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import android.graphics.BitmapFactory
import android.os.Looper
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.model.*
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.takahirom.roborazzi.captureRoboImage
import io.mockk.*
import java.io.File
import java.io.IOException
import java.util.UUID
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.cancel
import org.jellyfin.sdk.model.api.BaseItemDto
import org.junit.*
import org.junit.Assert.*
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.design.*
import org.justinvisser.wolfskin.tv.read.HomeReadFacade
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** Real Home actions keep button geometry, reject duplicate starts and launch only after the black handoff. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class HomeHeroStartingTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private lateinit var model: WolfskinHomeViewModel
private val conductarr = ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore())
private val session = WolfskinSession()
private val navigation = NavigationManager()
private val facade = mockk<HomeReadFacade>()
private val release = CompletableDeferred<BaseItemDto?>()
private val visible = mutableStateOf(true)
private var previousFixtures = false
private val server = JellyfinServer(UUID.randomUUID(), "Fixture", "https://example.invalid", null)
private val user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Mindel", accessToken = null)
private val shell = ShellState(listOf(ShellPerson("mindel", "Mindel", "M", false)), "mindel").apply { pickerVisible = false; pickerLaunch = false }
@After fun dispose() {
release.complete(null)
if (::model.isInitialized) model.viewModelScope.cancel()
conductarr.close()
ConductarrPreference.updateFixtures(previousFixtures)
}
private fun mount(reduced: Boolean = false) {
ConductarrPreference.initialize(rule.activity)
previousFixtures = ConductarrPreference.fixtures
ConductarrPreference.updateFixtures(true)
coEvery { facade.preparePlayback(user.id, any()) } coAnswers { release.await() }
every { facade.audiences } returns null
model = WolfskinHomeViewModel(rule.activity, facade, mockk(), navigation, WolfskinSeeAllLists(), conductarr, session)
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced, LocalHomePlaybackStart provides session.playback) { WolfskinTheme {
Box(Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) {
if (visible.value && navigation.backStack.last() !is Destination.Playback)
ShellSurface(shell) { modifier -> HomeDestination(shell, server, user, modifier, model) }
PlaybackHandoffVeil(session.playback)
}
} } }
await { model.snapshot.rows.firstOrNull()?.let { !it.skeleton && it.watchReady } == true }
rule.mainClock.advanceTimeBy(1200)
rule.mainClock.autoAdvance = false
}
@Test fun playSaysStartingThenFadesToBlackBeforeThePlayer() = successfulStart(false)
@Test fun reducedMotionCapsBothFadesAndStillStartsOnce() = successfulStart(true)
private fun successfulStart(reduced: Boolean) {
mount(reduced)
val directory = File(System.getProperty("wolfskin.captureDirectory"), "ui-polish/E04/" + if (reduced) "reduced" else "normal").apply { mkdirs() }
rule.onRoot().captureRoboImage(File(directory, "home-ready.png").absolutePath)
val width = rule.onNode(hasText("Resume", substring = true) and hasClickAction()).fetchSemanticsNode().boundsInRoot.width
val samples = mutableListOf<String>()
fun frame(phase: String, index: Int): Int {
val file = File(directory, phase + "-" + index.toString().padStart(2, '0') + ".png")
val clock = rule.mainClock.currentTime
rule.onRoot().captureRoboImage(file.absolutePath)
rule.waitForIdle()
assertEquals("Native capture cannot warm the animation clock", clock, rule.mainClock.currentTime)
val image = BitmapFactory.decodeFile(file.absolutePath)
val pixel = try { image.getPixel(600, 500) } finally { image.recycle() }
samples.add(phase + "," + index * 16 + "," + pixel + "," + navigation.backStack.size)
return android.graphics.Color.red(pixel) + android.graphics.Color.green(pixel) + android.graphics.Color.blue(pixel)
}
press()
val origin = rule.mainClock.currentTime
repeat(14) { index ->
if (index > 0) rule.mainClock.advanceTimeByFrame()
frame("home-start", index)
assertEquals("Every frame after OK is observed, including frame zero", origin + index * 16L, rule.mainClock.currentTime)
if (index >= 2) {
rule.onNodeWithText("Starting…").assertIsDisplayed()
assertEquals(width, rule.onNode(hasText("Starting…") and hasClickAction()).fetchSemanticsNode().boundsInRoot.width, 0f)
}
if (index == 3) press()
assertTrue(navigation.backStack.single() is Destination.Home)
}
val id = UUID.randomUUID()
release.complete(BaseItemDto(id = id, type = org.jellyfin.sdk.model.api.BaseItemKind.EPISODE))
await { session.playback.launch != null }
val duration = motionDuration(WolfskinTokens.Motion.base, reduced)
var elapsed = 0
val fading = mutableListOf<Int>()
while (navigation.backStack.last() !is Destination.Playback && elapsed <= duration + 48) {
fading.add(frame("home-black", elapsed / 16))
rule.mainClock.advanceTimeByFrame()
elapsed += 16
}
assertTrue("The player cannot open before the token-bounded fade", elapsed >= duration)
assertTrue("The page must visibly fade rather than wait and cut", fading.first() > fading.last())
assertTrue("The black handoff cannot flicker", fading.zipWithNext().all { (before, after) -> after <= before })
assertTrue("The final painted frame must be near black", fading.last() <= fading.first() / 10)
assertEquals(id, (navigation.backStack.last() as Destination.Playback).itemId)
assertEquals(2, navigation.backStack.size)
assertNull(session.playback.starting)
assertNull(session.playback.launch)
coVerify(exactly = 1) { facade.preparePlayback(user.id, any()) }
// Returning from the player requires a painted handoff first, not a pop in the same unrendered turn.
rule.mainClock.advanceTimeByFrame()
rule.onRoot().captureRoboImage(File(directory, "home-disposed.png").absolutePath)
rule.onAllNodesWithTag("wolfskin-shell").assertCountEquals(0)
File(directory, "samples.csv").writeText("phase,timeMs,pixel,stackSize\n" + samples.joinToString("\n"))
rule.runOnIdle { navigation.goBack(); androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
rule.mainClock.advanceTimeByFrame()
rule.onRoot().captureRoboImage(File(directory, "home-return-first.png").absolutePath)
rule.onNodeWithText("Resume", substring = true).assertIsDisplayed()
rule.onNodeWithText("Starting…").assertDoesNotExist()
}
@Test fun failedPreparationRestoresResumeAndShowsTheExistingRecoveryToast() {
mount()
val directory = File(System.getProperty("wolfskin.captureDirectory"), "ui-polish/E04/failure").apply { mkdirs() }
rule.onRoot().captureRoboImage(File(directory, "home-ready.png").absolutePath)
press()
repeat(14) { index ->
if (index > 0) rule.mainClock.advanceTimeByFrame()
rule.onRoot().captureRoboImage(File(directory, "preparing-" + index.toString().padStart(2, '0') + ".png").absolutePath)
if (index >= 2) rule.onNodeWithText("Starting…").assertIsDisplayed()
}
release.completeExceptionally(IOException("offline"))
await { model.toast != null }
repeat(14) { index ->
rule.mainClock.advanceTimeByFrame()
rule.onRoot().captureRoboImage(File(directory, "restored-" + index.toString().padStart(2, '0') + ".png").absolutePath)
}
rule.onNodeWithText("Resume", substring = true).assertIsDisplayed()
rule.onNodeWithText(RecoveryMessage.PlaybackUnavailable).assertIsDisplayed()
assertTrue(navigation.backStack.single() is Destination.Home)
}
@Test fun leavingHomeCancelsPreparationAndCannotLaunchALateResult() {
mount()
press()
assertEquals("Leaving Home must cancel an actual pending start", "hero-play", session.playback.starting)
rule.mainClock.advanceTimeBy(32)
rule.runOnIdle { visible.value = false; androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
rule.mainClock.advanceTimeBy(32)
release.complete(BaseItemDto(id = UUID.randomUUID(), type = org.jellyfin.sdk.model.api.BaseItemKind.MOVIE))
await { session.playback.starting == null }
rule.mainClock.advanceTimeBy(400)
assertTrue(navigation.backStack.single() is Destination.Home)
assertNull(session.playback.launch)
}
private fun press() {
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter, 0L) }
rule.runOnIdle { androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
}
private fun await(condition: () -> Boolean) {
rule.waitUntil(5000) {
androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications()
shadowOf(Looper.getMainLooper()).idle()
condition()
}
rule.waitForIdle()
}
}

View file

@ -17,7 +17,7 @@ import org.justinvisser.wolfskin.tv.read.HomeLibraryCache
class HomeLibraryCacheTest { class HomeLibraryCacheTest {
@Test fun savedHomeReopensWithEpisodeMetadataAndFullShelfCount() { @Test fun savedHomeReopensWithEpisodeMetadataAndFullShelfCount() {
val context = ApplicationProvider.getApplicationContext<Application>() val context = ApplicationProvider.getApplicationContext<Application>()
val episode = HomeTitle("episode-id", "The next episode", "episode", year = 2025, runtimeSeconds = 1800, overview = "Overview", poster = "poster", backdrop = "backdrop", seriesTitle = "A series", season = 2, episode = 3, resumeSeconds = 125.5, addedAt = "2026-11-05", genres = listOf("Drama"), held = true, tmdbId = 42) val episode = HomeTitle("episode-id", "The next episode", "episode", year = 2025, runtimeSeconds = 1800, overview = "Overview", poster = "poster", backdrop = "backdrop", seriesTitle = "A series", season = 2, episode = 3, resumeSeconds = 125.5, addedAt = "2026-11-05", genres = listOf("Drama"), held = true, tmdbId = 42, seriesId = "series-id", lastPlayedAt = 123456L)
val film = HomeTitle("film-id", "A film", "movie", year = 2024, runtimeSeconds = 5400, played = true, held = true, tmdbId = 43) val film = HomeTitle("film-id", "A film", "movie", year = 2024, runtimeSeconds = 5400, played = true, held = true, tmdbId = 43)
val library = HomeLibrary(listOf(episode), listOf(episode.copy(id = "next", resumeSeconds = 0.0)), listOf(film), mapOf("movies" to listOf(film)), mapOf("movies" to 84)) val library = HomeLibrary(listOf(episode), listOf(episode.copy(id = "next", resumeSeconds = 0.0)), listOf(film), mapOf("movies" to listOf(film)), mapOf("movies" to 84))
HomeLibraryCache(context).write("server:person", library, 12345L) HomeLibraryCache(context).write("server:person", library, 12345L)
@ -26,4 +26,19 @@ class HomeLibraryCacheTest {
assertEquals(12345L, saved.second) assertEquals(12345L, saved.second)
assertNull(HomeLibraryCache(context).read("server:someone-else")) assertNull(HomeLibraryCache(context).read("server:someone-else"))
} }
@Test fun preMergeCacheWithoutWatchIdentityOrTimestampStillReopens() {
val context = ApplicationProvider.getApplicationContext<Application>()
val cache = HomeLibraryCache(context)
val title = HomeTitle("legacy", "An episode", "episode", seriesTitle = "Series", resumeSeconds = 24.0, held = true)
cache.write("legacy-scope", HomeLibrary(resume = listOf(title)), 12L)
val store = org.justinvisser.wolfskin.conductarr.FileCacheStore(java.io.File(context.filesDir, "wolfskin-home"))
val entry = store.read("legacy-scope", "home:jellyfin")!!
val body = org.json.JSONObject(entry.body)
body.getJSONArray("resume").getJSONObject(0).apply { remove("seriesId"); remove("lastPlayedAt") }
store.write("legacy-scope", "home:jellyfin", entry.copy(body = body.toString()))
val saved = cache.read("legacy-scope")!!
assertEquals(title, saved.first.resume.single())
assertEquals(12L, saved.second)
}
} }

View file

@ -45,7 +45,7 @@ class HomeRecoveryTest {
val facade = mockk<HomeReadFacade>() val facade = mockk<HomeReadFacade>()
coEvery { facade.readWatch(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() coEvery { facade.readLibrary(any()) } returns HomeLibrary()
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore())) val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), WolfskinSession())
val refresh = async(Dispatchers.IO) { model.loadLibrary(server, personA, viewerA, retry = true) } val refresh = async(Dispatchers.IO) { model.loadLibrary(server, personA, viewerA, retry = true) }
started.await() started.await()
assertEquals(film.id, model.snapshot.rows.first().cards.first().id) assertEquals(film.id, model.snapshot.rows.first().cards.first().id)
@ -67,7 +67,7 @@ class HomeRecoveryTest {
coEvery { facade.readWatch(personA.id) } coAnswers { startedA.complete(Unit); responseA.await() } coEvery { facade.readWatch(personA.id) } coAnswers { startedA.complete(Unit); responseA.await() }
coEvery { facade.readWatch(personB.id) } throws IOException("fixture offline") coEvery { facade.readWatch(personB.id) } throws IOException("fixture offline")
coEvery { facade.readLibrary(any()) } returns HomeLibrary() coEvery { facade.readLibrary(any()) } returns HomeLibrary()
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore())) val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), WolfskinSession())
val requestA = async(Dispatchers.IO) { model.loadLibrary(server, personA, viewerA) } val requestA = async(Dispatchers.IO) { model.loadLibrary(server, personA, viewerA) }
startedA.await() startedA.await()
assertTrue(model.snapshot.rows.any { row -> row.cards.any { it.id == film.id } }) assertTrue(model.snapshot.rows.any { row -> row.cards.any { it.id == film.id } })
@ -88,7 +88,7 @@ class HomeRecoveryTest {
val cache = mockk<HomeLibraryCache>() val cache = mockk<HomeLibraryCache>()
every { cache.read(any()) } returns null every { cache.read(any()) } returns null
every { cache.write(any(), any(), any()) } throws IOException("fixture disk full") every { cache.write(any(), any(), any()) } throws IOException("fixture disk full")
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore())) val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), WolfskinSession())
model.loadLibrary(server, personA, viewerA) model.loadLibrary(server, personA, viewerA)
assertTrue(model.snapshot.jellyfinAvailable) assertTrue(model.snapshot.jellyfinAvailable)
assertEquals(film.id, model.snapshot.rows.first().cards.first().id) assertEquals(film.id, model.snapshot.rows.first().cards.first().id)
@ -101,7 +101,7 @@ class HomeRecoveryTest {
coEvery { facade.readLibrary(any()) } returns HomeLibrary() coEvery { facade.readLibrary(any()) } returns HomeLibrary()
val cache = mockk<HomeLibraryCache>(relaxed = true) val cache = mockk<HomeLibraryCache>(relaxed = true)
every { cache.read(any()) } returns null every { cache.read(any()) } returns null
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore())) val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), WolfskinSession())
model.loadLibrary(server, personA, viewerA.copy(key = "household-profile", name = "Household display name")) model.loadLibrary(server, personA, viewerA.copy(key = "household-profile", name = "Household display name"))
assertTrue(model.snapshot.jellyfinAvailable) assertTrue(model.snapshot.jellyfinAvailable)
assertEquals(film.id, model.snapshot.rows.first().cards.first().id) assertEquals(film.id, model.snapshot.rows.first().cards.first().id)

View file

@ -64,7 +64,7 @@ class HomeSeeAllNavigationTest {
} }
val server = JellyfinServer(UUID.fromString("00000000-0000-0000-0000-000000000001"), "Fixture", "https://example.invalid", null) val server = JellyfinServer(UUID.fromString("00000000-0000-0000-0000-000000000001"), "Fixture", "https://example.invalid", null)
val user = JellyfinUser(id = UUID.fromString("00000000-0000-0000-0000-000000000002"), name = "Justin", serverId = server.id, accessToken = null) val user = JellyfinUser(id = UUID.fromString("00000000-0000-0000-0000-000000000002"), name = "Justin", serverId = server.id, accessToken = null)
val model = WolfskinHomeViewModel(rule.activity, mockk(), mockk(), navigation, lists, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore())) val model = WolfskinHomeViewModel(rule.activity, mockk(), mockk(), navigation, lists, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), WolfskinSession())
val facade = mockk<LibraryReadFacade>() val facade = mockk<LibraryReadFacade>()
rule.setContent { rule.setContent {
val destination = navigation.backStack.last() val destination = navigation.backStack.last()
@ -90,7 +90,7 @@ class HomeSeeAllNavigationTest {
try { try {
rule.onNodeWithTag("library-grid").assertIsDisplayed() rule.onNodeWithTag("library-grid").assertIsDisplayed()
exerciseGrid(grid) exerciseGrid(grid)
press(Key.Back) press(Key.Escape)
rule.waitForIdle() rule.waitForIdle()
assertTrue(navigation.backStack.last() is Destination.Home) assertTrue(navigation.backStack.last() is Destination.Home)
assertEquals(row.id, snapshot.rows[shell.view.row].id) assertEquals(row.id, snapshot.rows[shell.view.row].id)

View file

@ -0,0 +1,413 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import android.graphics.BitmapFactory
import android.os.Looper
import androidx.compose.foundation.background
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.CurrentUser
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.*
import com.github.damontecres.wholphin.preferences.*
import com.github.damontecres.wholphin.services.*
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.takahirom.roborazzi.captureRoboImage
import io.mockk.*
import java.io.File
import java.util.UUID
import kotlinx.coroutines.cancel
import org.junit.*
import org.junit.Assert.*
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
import org.justinvisser.wolfskin.design.*
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
private const val PLAYER_PAINT = 0xff204060
/** Native forward cuts paint by 32 ms, Back by 64 ms; all outgoing semantics retire by 64 ms. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class NavigationCutTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private lateinit var host: PolishAppHost
@After fun dispose() { if (::host.isInitialized) host.close() }
@Test fun titleAndTabPaintWithinTwoFramesBackWithinFour() = cuts(false)
@Test fun reducedMotionKeepsOpaqueCutsAndRetiresOutgoingByFourFrames() = cuts(true)
private fun cuts(reduced: Boolean) {
host = PolishAppHost(rule)
host.mount(picker = false, reduced = reduced)
val directory = File(System.getProperty("wolfskin.captureDirectory"), "ui-polish/E01/" + if (reduced) "reduced" else "normal").apply { mkdirs() }
val samples = mutableListOf<String>()
val homeCard = SemanticsMatcher("Home card") {
it.config.contains(androidx.compose.ui.semantics.SemanticsProperties.TestTag) &&
it.config[androidx.compose.ui.semantics.SemanticsProperties.TestTag].startsWith("home-card:")
}
// Establish a painted Home and a focused shelf card before starting the navigation clock.
rule.onRoot().captureRoboImage(File(directory, "home-precondition.png").absolutePath)
rule.onNodeWithText("Who’s watching?").assertDoesNotExist()
rule.runOnIdle { host.session.state!!.focusNav(0); androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
repeat(2) {
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionDown, 0L) }
rule.waitForIdle()
}
assertFalse("Home card activation cannot start from a picker", host.session.state!!.pickerVisible)
assertNotNull("The OK precondition is a shelf card, not the nav or hero", host.session.state!!.contentFocus?.cardKey)
assertTrue(host.navigation.backStack.single() is Destination.Home)
rule.onRoot().captureRoboImage(File(directory, "home-card-ready.png").absolutePath)
rule.mainClock.autoAdvance = false
fun strip(phase: String, previousFrame: File) {
rule.runOnIdle { androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
val origin = rule.mainClock.currentTime
var firstIncoming: Int? = null
// These fixture-art probes are outside the entering title copy. Composition alone cannot prove paint.
val x = intArrayOf(1500, 1700, 1900, 1500, 1700, 1900)
val y = intArrayOf(300, 500, 700, 700, 900, 300)
val outgoingPaint = IntArray(x.size)
val paint = IntArray(x.size)
val previous = BitmapFactory.decodeFile(previousFrame.absolutePath)
val opaqueAvatar = try {
for (point in x.indices) outgoingPaint[point] = previous.getPixel(x[point], y[point])
previous.getPixel(1774, 90)
} finally { previous.recycle() }
try {
repeat(21) { index ->
if (index > 0) rule.mainClock.advanceTimeByFrame()
val file = File(directory, phase + "-" + index.toString().padStart(2, '0') + ".png")
rule.onRoot().captureRoboImage(file.absolutePath)
rule.waitForIdle()
val shells = rule.onAllNodesWithTag("wolfskin-shell").fetchSemanticsNodes().size
val titles = rule.onAllNodesWithTag("title-page").fetchSemanticsNodes().size
val cards = rule.onAllNodes(homeCard).fetchSemanticsNodes().size
val libraries = rule.onAllNodesWithTag("library-grid").fetchSemanticsNodes().size
val image = BitmapFactory.decodeFile(file.absolutePath)
val avatar = try {
for (point in x.indices) paint[point] = image.getPixel(x[point], y[point])
image.getPixel(1774, 90)
} finally { image.recycle() }
samples.add("$phase,${index * 16},$shells,$titles,$cards,$libraries,$avatar,${paint.joinToString(";")}")
assertEquals("Observing a native frame cannot warm the clock", origin + index * 16L, rule.mainClock.currentTime)
assertEquals("The persistent avatar cannot acquire whole-screen transition alpha or a second blended nav", opaqueAvatar, avatar)
val composed = when (phase) {
"open-title" -> titles == 1
"back" -> cards > 0
else -> libraries == 1
}
val incoming = composed && !paint.contentEquals(outgoingPaint)
if (incoming && firstIncoming == null) {
firstIncoming = index
val limit = if (phase == "back") 4 else 2
assertTrue("$phase first painted incoming after $index frames (${index * 16} ms), exceeding $limit frames", index <= limit)
}
if (firstIncoming != null) assertTrue("Native paint cannot revert to the outgoing screen", incoming)
if (index >= 4) {
assertEquals("By 64 ms exactly one destination owns the nav semantics", 1, shells)
assertEquals(if (phase == "open-title") 1 else 0, titles)
assertEquals(if (phase == "tab") 1 else 0, libraries)
if (phase != "back") assertEquals("Outgoing Home cards must retire by 64 ms", 0, cards)
else assertTrue("Back must restore Home, not leave a blank destination", cards > 0)
}
}
assertNotNull("Incoming destination did not appear within the 320 ms strip", firstIncoming)
} finally {
File(directory, "samples.csv").writeText("phase,timeMs,shells,titles,homeCards,libraries,avatarPixel,backdropPixels\n" + samples.joinToString("\n"))
}
}
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter, 0L) }
assertEquals("OK must change the back stack before the first observed frame", 2, host.navigation.backStack.size)
strip("open-title", File(directory, "home-card-ready.png"))
rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() }
strip("back", File(directory, "open-title-20.png"))
// Settle focus before OK; that focus animation is not a screen transition.
rule.mainClock.autoAdvance = true
rule.runOnIdle { host.session.state!!.focusNav(2); androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
rule.onRoot().captureRoboImage(File(directory, "tab-ready.png").absolutePath)
assertTrue(host.navigation.backStack.single() is Destination.Home)
rule.mainClock.autoAdvance = false
rule.runOnIdle { host.session.state!!.key(ShellKey.Ok) }
strip("tab", File(directory, "tab-ready.png"))
}
@Test fun playbackVeilIgnoresKeysAndBackThenReturnsBackOwnershipToHome() {
host = PolishAppHost(rule)
host.mount(picker = false)
rule.mainClock.autoAdvance = false
var launches = 0
rule.runOnIdle { host.session.playback.begin("hero-play"); host.session.playback.ready { launches++ } }
rule.mainClock.advanceTimeBy(32)
val revision = host.session.state!!.inputRevision
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionRight); pressKey(Key.DirectionCenter); pressKey(Key.Escape) }
rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() }
assertEquals(revision, host.session.state!!.inputRevision)
assertEquals(0, launches)
rule.mainClock.advanceTimeBy(200)
assertEquals(1, launches)
assertNull(host.session.playback.starting)
rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() }
assertTrue(host.session.state!!.navFocused)
assertFalse(rule.activity.isFinishing)
}
@Test fun backBeforeTheBlackVeilCancelsThePendingStartWithoutExitingHome() {
host = PolishAppHost(rule)
host.mount(picker = false)
rule.runOnIdle { host.session.playback.begin("hero-play") }
rule.mainClock.advanceTimeBy(32)
rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() }
assertNull(host.session.playback.starting)
assertTrue(host.session.state!!.navFocused)
assertFalse(rule.activity.isFinishing)
}
@Test fun fullRootPlaybackHandoffNeverRepaintsHome() = rootHandoff(false)
@Test fun reducedMotionFullRootHandoffNeverRepaintsHome() = rootHandoff(true)
private fun rootHandoff(reduced: Boolean) {
host = PolishAppHost(rule)
host.playerReady = false
host.mount(picker = false, reduced = reduced)
val directory = File(System.getProperty("wolfskin.captureDirectory"), "ui-polish/E04/root-" + if (reduced) "reduced" else "normal").apply { mkdirs() }
rule.onRoot().captureRoboImage(File(directory, "home-ready.png").absolutePath)
rule.mainClock.autoAdvance = false
val destination = Destination.Playback(UUID.randomUUID(), 0L)
rule.runOnIdle {
host.session.playback.begin("hero-play")
host.session.playback.ready { host.navigation.navigateTo(destination) }
}
val origin = rule.mainClock.currentTime
val samples = mutableListOf<String>()
var navigationTime: Long? = null
var playerPainted = false
try {
repeat(21) { index ->
if (index > 0) rule.mainClock.advanceTimeByFrame()
val file = File(directory, "handoff-" + index.toString().padStart(2, '0') + ".png")
rule.onRoot().captureRoboImage(file.absolutePath)
val logicalPlayer = host.navigation.backStack.last() == destination
val time = rule.mainClock.currentTime - origin
val image = BitmapFactory.decodeFile(file.absolutePath)
try {
val pixel = image.getPixel(600, 500)
val avatar = image.getPixel(1774, 90)
samples.add("$time,$logicalPlayer,$pixel,$avatar")
if (logicalPlayer) {
if (navigationTime == null) navigationTime = time
assertTrue("After handoff only opaque black or the incoming player may paint; never old Home",
pixel == android.graphics.Color.BLACK || pixel == PLAYER_PAINT.toInt())
assertEquals("The old Home nav cannot flash through the completed veil", pixel, avatar)
assertEquals(pixel, image.getPixel(100, 900))
assertEquals(pixel, image.getPixel(1800, 900))
if (pixel == PLAYER_PAINT.toInt()) playerPainted = true
if (pixel == android.graphics.Color.BLACK && time == navigationTime + 16) {
val revision = host.session.state!!.inputRevision
rule.onRoot().performKeyInput {
pressKey(Key.DirectionRight, 0L); pressKey(Key.DirectionCenter, 0L); pressKey(Key.Escape, 0L)
}
rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() }
assertEquals("Keys cannot reach old Home during the pending native player scene", revision, host.session.state!!.inputRevision)
assertEquals(destination, host.navigation.backStack.last())
}
if (time == navigationTime + 48) rule.runOnIdle {
host.playerReady = true
androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications()
}
}
} finally { image.recycle() }
assertEquals("The full-root probe cannot warm the manual clock", origin + index * 16L, rule.mainClock.currentTime)
}
assertTrue("The incoming player must eventually be uncovered", playerPainted)
assertEquals(2, host.navigation.backStack.size)
rule.onAllNodesWithTag("wolfskin-shell").assertCountEquals(0)
assertNull(host.session.playback.starting)
assertNull(host.session.playback.launch)
} finally {
File(directory, "samples.csv").writeText("timeMs,logicalPlayer,bodyPixel,avatarPixel\n" + samples.joinToString("\n"))
}
}
@Test fun setupHostKeepsOpaquePaintAndRetiresOutgoingWithinFourFrames() = setupCuts(false)
@Test fun reducedMotionSetupHostKeepsTheSameCutDeadlines() = setupCuts(true)
private fun setupCuts(reduced: Boolean) {
val server = JellyfinServer(UUID.randomUUID(), "Fixture", "https://example.invalid", null)
val user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = null)
val stack = mutableStateListOf<SetupDestination>(SetupDestination.ServerList)
val colors = mapOf("ServerList" to 0xff304060.toInt(), "UserList" to 0xff406030.toInt(), "AppContent" to 0xff603040.toInt())
val screensaver = mockk<ScreensaverService>()
every { screensaver.state } returns kotlinx.coroutines.flow.MutableStateFlow(ScreensaverState(false, false, false, false))
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { androidx.tv.material3.MaterialTheme {
com.github.damontecres.wholphin.MainContent(stack, NavigationManager(), UserPreferences(AppPreferences.getDefaultInstance(), null),
mockk(relaxed = true), screensaver, kotlinx.coroutines.flow.MutableStateFlow<com.github.damontecres.wholphin.data.CurrentUser?>(null), mockk(relaxed = true),
Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight),
setupInterceptor = { destination, modifier ->
androidx.compose.foundation.layout.Box(modifier.then(Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight))
.then(Modifier.background(androidx.compose.ui.graphics.Color(colors.getValue(destination.javaClass.simpleName))))) {
androidx.tv.material3.Text(destination.javaClass.simpleName)
}
true
})
} } }
val directory = File(System.getProperty("wolfskin.captureDirectory"), "ui-polish/E01/setup-" + if (reduced) "reduced" else "normal").apply { mkdirs() }
val samples = mutableListOf<String>()
rule.onRoot().captureRoboImage(File(directory, "setup-precondition.png").absolutePath)
rule.onAllNodesWithText("ServerList").assertCountEquals(1)
rule.mainClock.autoAdvance = false
for (destination in listOf(SetupDestination.UserList(server), SetupDestination.AppContent(CurrentUser(server, user)))) {
val previous = stack.single().javaClass.simpleName
val next = destination.javaClass.simpleName
rule.runOnIdle { stack[0] = destination; androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
val origin = rule.mainClock.currentTime
var firstIncoming: Int? = null
try {
repeat(21) { index ->
if (index > 0) rule.mainClock.advanceTimeByFrame()
val file = File(directory, next + "-" + index.toString().padStart(2, '0') + ".png")
rule.onRoot().captureRoboImage(file.absolutePath)
rule.waitForIdle()
val counts = colors.keys.associateWith { rule.onAllNodesWithText(it).fetchSemanticsNodes().size }
val image = BitmapFactory.decodeFile(file.absolutePath)
val pixel = try {
val sample = image.getPixel(600, 500)
assertEquals("The entire setup scene must share its opaque paint", sample, image.getPixel(100, 500))
assertEquals(sample, image.getPixel(1500, 500))
assertEquals(sample, image.getPixel(600, 900))
sample
} finally { image.recycle() }
samples.add("$next,${index * 16},${counts.getValue(previous)},${counts.getValue(next)},$pixel")
assertEquals("Native setup observation cannot warm the clock", origin + index * 16L, rule.mainClock.currentTime)
val incoming = pixel == colors.getValue(next)
assertTrue("Setup native paint is exactly the outgoing or incoming scene, never an alpha composite",
incoming || pixel == colors.getValue(previous))
if (incoming && firstIncoming == null) {
firstIncoming = index
assertTrue("$next first visible after $index frames (${index * 16} ms), not within two frames", index <= 2)
}
if (firstIncoming != null) assertTrue("Incoming native paint cannot revert to the outgoing scene", incoming)
if (index >= 4) {
assertEquals("By 64 ms only the incoming setup screen may remain in semantics", 1, counts.values.sum())
rule.onNodeWithText(previous).assertDoesNotExist()
rule.onAllNodesWithText(next).assertCountEquals(1)
}
}
assertNotNull("Setup screen did not appear within 320 ms", firstIncoming)
} finally {
File(directory, "samples.csv").writeText("destination,timeMs,previous,incoming,pixel\n" + samples.joinToString("\n"))
}
}
}
}
/** Real shell integration with fixture data and an injectable saved-user switch. */
internal class PolishAppHost(private val rule: AndroidComposeTestRule<ActivityScenarioRule<ComponentActivity>, ComponentActivity>) : AutoCloseable {
val server = JellyfinServer(UUID.fromString("00000000-0000-0000-0000-000000000001"), "Fixture", "https://example.invalid", null)
var users = listOf("Justin", "Mindel", "Shared").mapIndexed { index, name ->
JellyfinUser(id = UUID.fromString("00000000-0000-0000-0000-00000000000${index + 2}"), serverId = server.id, name = name, accessToken = null)
}
val session = WolfskinSession()
val navigation = NavigationManager()
val repository = mockk<ServerRepository>(relaxed = true)
val setup = mockk<SetupNavigationManager>(relaxed = true)
private val conductarr = ConductarrSession(MemoryCacheStore())
private val models = mutableListOf<WolfskinShellViewModel>()
private var oldFixtures = false
private var oldScreens = true
private var current by mutableStateOf(users.first())
val model get() = models.last()
var reduceMotion by mutableStateOf(false)
var playerReady by mutableStateOf(true)
fun showUser(user: JellyfinUser) { current = user }
fun mount(picker: Boolean = true, reduced: Boolean = false, fixtures: Boolean = true) {
reduceMotion = reduced
ConductarrPreference.initialize(rule.activity)
WolfskinScreenPreference.initialize(rule.activity)
oldFixtures = ConductarrPreference.fixtures
oldScreens = WolfskinScreenPreference.enabled
ConductarrPreference.updateFixtures(fixtures)
WolfskinScreenPreference.enabled = true
val backdrop = mockk<BackdropService>(relaxed = true)
every { backdrop.backdropFlow } returns kotlinx.coroutines.flow.MutableStateFlow(BackdropResult.NONE)
coEvery { repository.serverDao.getServer(server.id) } answers { JellyfinServerUsers(server, users) }
coEvery { repository.changeUser(server, any()) } coAnswers { CurrentUser(server, secondArg()) }
every { setup.navigateTo(any()) } answers {
val destination = firstArg<SetupDestination>()
if (destination is SetupDestination.AppContent) current = destination.current.user
}
rule.setContent {
CompositionLocalProvider(LocalReducedMotion provides reduceMotion) {
key(current.id) {
val model = remember {
WolfskinShellViewModel(rule.activity, session, repository, navigation, setup, backdrop,
mockk(relaxed = true), WolfskinSeeAllLists(), conductarr, mockk(relaxed = true),
org.justinvisser.wolfskin.tv.update.FakeUpdateController(), mockk(relaxed = true)).also { models.add(it) }
}
DisposableEffect(model) { onDispose { model.viewModelScope.cancel() } }
WolfskinApp(server, current, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null),
Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight), model,
homeContent = { shell, _, user, modifier ->
val snapshot = remember(user.id) { HomeFixture(rule.activity).snapshot(user.name!!.lowercase()) }
HomeScreen(shell, snapshot, modifier) { action ->
if (action is HomeAction.Details || action is HomeAction.Play)
navigation.navigateTo(Destination.Wolfskin(titleRoute("Locke")))
}
},
titleContent = { route, shell, _ ->
val title = remember(route) {
val fixture = TitleFixture(rule.activity.assets.open("wolfskin-titles.json").bufferedReader().use { org.json.JSONObject(it.readText()) })
TitleState(fixture.title("Locke", shell.person.key), shell.person) {
if (it == TitleAction.Pop) navigation.goBack()
}
}
androidx.activity.compose.BackHandler { title.back(shell) }
TitleSurface(title, shell)
},
fullScreenContent = { destination, modifier ->
assertTrue(destination is Destination.Playback)
androidx.compose.foundation.layout.Box(modifier.then(if (playerReady) Modifier.background(androidx.compose.ui.graphics.Color(PLAYER_PAINT)) else Modifier)
.testTag("polish-player"))
})
}
}
}
await { session.state?.people?.size == users.size && model.signedInPerson == "justin" }
rule.runOnIdle {
session.state!!.pickerVisible = picker
session.state!!.pickerLaunch = picker
androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications()
}
rule.mainClock.advanceTimeBy(1200)
}
fun await(condition: () -> Boolean) {
rule.waitUntil(10_000) {
androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications()
shadowOf(Looper.getMainLooper()).idle()
if (!rule.mainClock.autoAdvance) rule.mainClock.advanceTimeByFrame()
condition()
}
rule.waitForIdle()
}
override fun close() {
models.forEach { it.viewModelScope.cancel() }
conductarr.close()
ConductarrPreference.updateFixtures(oldFixtures)
WolfskinScreenPreference.enabled = oldScreens
}
}

View file

@ -0,0 +1,185 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import android.graphics.BitmapFactory
import androidx.activity.ComponentActivity
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import com.github.damontecres.wholphin.data.CurrentUser
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.services.SetupDestination
import com.github.takahirom.roborazzi.captureRoboImage
import io.mockk.*
import java.io.File
import java.io.IOException
import kotlinx.coroutines.CompletableDeferred
import org.junit.*
import org.junit.Assert.*
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.design.*
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** A frozen, inert picker protects the selected identity across an asynchronous app replacement. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class PersonCommitTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private lateinit var host: PolishAppHost
private val release = CompletableDeferred<Unit>()
@After fun dispose() { release.complete(Unit); if (::host.isInitialized) host.close() }
@Test fun choosingMindelCoversTheSwitchUntilTheirHomeCanPaint() = heldSwitch(false)
@Test fun reducedMotionUsesOnlyTheShortFullPickerFade() = heldSwitch(true)
private fun heldSwitch(reduced: Boolean) {
host = PolishAppHost(rule)
host.mount(reduced = reduced)
coEvery { host.repository.changeUser(host.server, any()) } coAnswers {
release.await()
CurrentUser(host.server, secondArg())
}
var pendingUser: JellyfinUser? = null
every { host.setup.navigateTo(any()) } answers { pendingUser = (firstArg() as SetupDestination.AppContent).current.user }
rule.mainClock.autoAdvance = false
press(listOf(Key.DirectionRight))
rule.mainClock.advanceTimeBy(400)
val directory = File(System.getProperty("wolfskin.captureDirectory"), "ui-polish/P08/" + if (reduced) "reduced" else "normal").apply { mkdirs() }
rule.onRoot().captureRoboImage(File(directory, "picker.png").absolutePath)
val commit = choose(listOf(Key.DirectionCenter))
val origin = commit.startFrameNanos
val clockOrigin = rule.mainClock.currentTime
val samples = mutableListOf<String>()
val others = mutableListOf<Int>()
val chosen = mutableListOf<Int>()
val bg = WolfskinTokens.Color.bg.toArgb()
repeat(31) { index ->
val timeMs = rule.mainClock.currentTime - clockOrigin
assertEquals("Frame strips advance only in 16 ms steps", index * 16L, timeMs)
rule.onNodeWithText("Sign in as", substring = true).assertDoesNotExist()
val file = File(directory, "commit-" + index.toString().padStart(2, '0') + ".png")
rule.onRoot().captureRoboImage(file.absolutePath)
assertEquals("Capturing a frame cannot advance animation time", timeMs, rule.mainClock.currentTime - clockOrigin)
val image = BitmapFactory.decodeFile(file.absolutePath)
try {
val other = image.getPixel(600, 500)
val selected = image.getPixel(900, 500)
others.add(android.graphics.Color.red(other))
chosen.add(selected)
if (timeMs >= if (reduced) 80 else 320) {
val pixels = IntArray(image.width * image.height)
image.getPixels(pixels, 0, image.width, 0, 0, image.width, image.height)
assertTrue("Only opaque background may remain while sign-in is pending", pixels.all { it == bg })
}
samples.add("$timeMs,$other,$selected")
} finally { image.recycle() }
rule.mainClock.advanceTimeByFrame()
}
assertTrue("The nonchosen person must fade monotonically to background", others.zipWithNext().all { (before, after) -> after <= before })
assertNotEquals(others.first(), others.last())
if (!reduced) assertTrue("The chosen avatar stays intact throughout phase one", chosen.take(10).all { it == chosen.first() })
File(directory, "samples.csv").writeText("timeMs,other,chosen\n" + samples.joinToString("\n"))
val shell = host.session.state!!
val revision = shell.inputRevision
val stack = host.navigation.backStack.toList()
press(listOf(Key.DirectionRight, Key.DirectionCenter, Key.Escape))
rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() }
rule.onRoot().performTouchInput { click(androidx.compose.ui.geometry.Offset(600f, 500f)) }
assertEquals(revision, shell.inputRevision)
assertEquals("mindel", shell.person.key)
assertEquals(stack, host.navigation.backStack.toList())
if (reduced) {
rule.runOnIdle { host.reduceMotion = false; androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
rule.mainClock.advanceTimeByFrame()
val file = File(directory, "motion-toggle.png")
rule.onRoot().captureRoboImage(file.absolutePath)
val image = BitmapFactory.decodeFile(file.absolutePath)
try {
assertEquals("A completed veil cannot uncover the chosen person when motion changes", bg, image.getPixel(900, 500))
assertEquals(bg, image.getPixel(100, 80))
} finally { image.recycle() }
}
release.complete(Unit)
host.await { pendingUser != null }
rule.mainClock.advanceTimeBy(160)
assertNotNull("Completing switch work cannot uncover an unauthenticated replacement app", host.session.personCommit)
rule.runOnIdle { host.showUser(pendingUser!!) }
host.await { host.model.signedInPerson == "mindel" && host.session.personCommit == null }
assertEquals(origin, commit.startFrameNanos)
rule.onAllNodesWithText("Sex and the City")[0].assertIsDisplayed()
rule.onRoot().captureRoboImage(File(directory, "home-first-frame.png").absolutePath)
rule.mainClock.advanceTimeBy(WolfskinTokens.Motion.heroFade.toLong() + 32)
rule.onRoot().captureRoboImage(File(directory, "home.png").absolutePath)
coVerify(exactly = 1) { host.repository.changeUser(host.server, any()) }
}
@Test fun samePersonRunsTheChoreographyWithoutSwitchingCredentials() {
host = PolishAppHost(rule)
host.mount()
rule.mainClock.autoAdvance = false
choose(listOf(Key.DirectionCenter))
assertNotNull(host.session.personCommit)
finishTimeline()
assertNull(host.session.personCommit)
rule.onAllNodesWithText("Yu Yu Hakusho")[0].assertIsDisplayed()
coVerify(exactly = 0) { host.repository.changeUser(any(), any()) }
}
@Test fun pinProtectedPersonReturnsToSetupWithoutChangingCredentials() = protectedPerson(pin = "protected")
@Test fun requireLoginPersonReturnsToSetupWithoutChangingCredentials() = protectedPerson(requireLogin = true)
private fun protectedPerson(pin: String? = null, requireLogin: Boolean = false) {
host = PolishAppHost(rule)
host.users = host.users.mapIndexed { index, user -> if (index == 1) user.copy(pin = pin, requireLogin = requireLogin) else user }
host.mount(fixtures = false)
rule.mainClock.autoAdvance = false
choose(listOf(Key.DirectionRight, Key.DirectionCenter))
assertNotNull(host.session.personCommit)
awaitSwitchWork()
verify(exactly = 0) { host.setup.navigateTo(any()) }
finishTimeline()
assertNull(host.session.personCommit)
coVerify(exactly = 0) { host.repository.changeUser(any(), any()) }
verify(exactly = 1) { host.setup.navigateTo(SetupDestination.UserList(host.server)) }
}
@Test fun failedCredentialSwitchRevealsSetupOnlyAfterThePickerIsCovered() {
host = PolishAppHost(rule)
host.mount(fixtures = false)
coEvery { host.repository.changeUser(any(), any()) } throws IOException("switch failed")
rule.mainClock.autoAdvance = false
choose(listOf(Key.DirectionRight, Key.DirectionCenter))
awaitSwitchWork()
finishTimeline()
assertNull(host.session.personCommit)
verify(exactly = 1) { host.setup.navigateTo(SetupDestination.UserList(host.server)) }
}
private fun choose(keys: List<Key>): PersonCommit {
press(keys)
val commit = requireNotNull(host.session.personCommit)
rule.runOnIdle { androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
rule.mainClock.advanceTimeUntil(1000) { commit.startFrameNanos != null }
return commit
}
private fun finishTimeline() {
repeat(WolfskinTokens.Motion.base * 2 / 16 + 2) {
rule.mainClock.advanceTimeByFrame()
rule.waitForIdle()
}
}
private fun awaitSwitchWork() {
rule.waitUntil(5000) {
org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
host.session.personCommit?.switchEnded == true
}
}
private fun press(keys: List<Key>) = rule.onNodeWithTag("wolfskin-shell").performKeyInput { keys.forEach { pressKey(it, 0L) } }
}

View file

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

View file

@ -87,7 +87,7 @@ class PersonSwitchThreadingTest {
coEvery { libraries.readDestinations(any()) } returns emptyMap() coEvery { libraries.readDestinations(any()) } returns emptyMap()
val session = WolfskinSession() val session = WolfskinSession()
val model = WolfskinShellViewModel(context, session, repository, mockk(relaxed = true), setup, mockk(relaxed = true), val model = WolfskinShellViewModel(context, session, repository, mockk(relaxed = true), setup, mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()) libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController(), trailerReturn = mockk(relaxed = true))
val clients = mutableListOf<ConductarrClient>() val clients = mutableListOf<ConductarrClient>()
try { try {
model.load(server, justin) model.load(server, justin)
@ -163,7 +163,7 @@ class PersonSwitchThreadingTest {
coEvery { libraries.readDestinations(any()) } returns emptyMap() coEvery { libraries.readDestinations(any()) } returns emptyMap()
val session = WolfskinSession() val session = WolfskinSession()
val model = WolfskinShellViewModel(context, session, repository, mockk(relaxed = true), setup, mockk(relaxed = true), val model = WolfskinShellViewModel(context, session, repository, mockk(relaxed = true), setup, mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()) libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController(), trailerReturn = mockk(relaxed = true))
try { try {
model.load(server, justin) model.load(server, justin)
requireNotNull(model.onForeground(server, justin)).join() requireNotNull(model.onForeground(server, justin)).join()
@ -219,8 +219,8 @@ class PersonSwitchThreadingTest {
coEvery { home.readLibrary(user.id) } returns org.justinvisser.wolfskin.design.HomeLibrary() coEvery { home.readLibrary(user.id) } returns org.justinvisser.wolfskin.design.HomeLibrary()
val cache = mockk<org.justinvisser.wolfskin.tv.read.HomeLibraryCache>(relaxed = true) val cache = mockk<org.justinvisser.wolfskin.tv.read.HomeLibraryCache>(relaxed = true)
every { cache.read(any()) } returns null every { cache.read(any()) } returns null
val homeModel = WolfskinHomeViewModel(context, home, cache, mockk(relaxed = true), WolfskinSeeAllLists(), conductarr) val homeModel = WolfskinHomeViewModel(context, home, cache, mockk(relaxed = true), WolfskinSeeAllLists(), conductarr, WolfskinSession())
val titleModel = WolfskinTitleViewModel(context, mockk(), mockk(), mockk(relaxed = true), conductarr, repository) val titleModel = WolfskinTitleViewModel(context, mockk(), mockk(), mockk(relaxed = true), conductarr, repository, mockk(relaxed = true), trailerReturn = mockk(relaxed = true))
try { try {
conductarr.select("${server.id}:${user.id}", user.accessToken, fake.url) conductarr.select("${server.id}:${user.id}", user.accessToken, fake.url)
conductarr.authenticate() conductarr.authenticate()

View file

@ -52,7 +52,7 @@ class PlayerHandoffTest {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
shell = ShellState(listOf(ShellPerson("mindel", "Mindel", "M", false)), "mindel").apply { pickerVisible = false } shell = ShellState(listOf(ShellPerson("mindel", "Mindel", "M", false)), "mindel").apply { pickerVisible = false }
model = WolfskinHomeViewModel(rule.activity, facade, mockk(), navigation, WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore())) model = WolfskinHomeViewModel(rule.activity, facade, mockk(), navigation, WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), WolfskinSession())
} }
@Test fun failedPlaybackReadLeavesSavedHomeFocusedAndDrawsTheExactOfflineToast() { @Test fun failedPlaybackReadLeavesSavedHomeFocusedAndDrawsTheExactOfflineToast() {
@ -78,15 +78,30 @@ class PlayerHandoffTest {
} }
@Test fun successfulPlaybackUsesTheRealFullscreenDestinationAndBackKeepsTheResumeCard() { @Test fun successfulPlaybackUsesTheRealFullscreenDestinationAndBackKeepsTheResumeCard() {
val title = HomeFixture(rule.activity).snapshot("mindel").rows.first().cards.first() val snapshot = HomeFixture(rule.activity).snapshot("mindel")
val title = snapshot.rows.first().cards.first()
val id = UUID.fromString("00000000-0000-0000-0000-000000000003") val id = UUID.fromString("00000000-0000-0000-0000-000000000003")
coEvery { facade.preparePlayback(user.id, title) } returns org.jellyfin.sdk.model.api.BaseItemDto( coEvery { facade.preparePlayback(user.id, title) } returns org.jellyfin.sdk.model.api.BaseItemDto(
id = id, type = org.jellyfin.sdk.model.api.BaseItemKind.EPISODE, id = id, type = org.jellyfin.sdk.model.api.BaseItemKind.EPISODE,
userData = playbackUserData(id, 11_362_740_000L, playCount = 2)) userData = playbackUserData(id, 11_362_740_000L, playCount = 2))
val view = shell.view val view = shell.view
view.cards["hero"] = 0 view.cards["hero"] = 0
rule.setContent { androidx.compose.runtime.CompositionLocalProvider(LocalHomePlaybackStart provides model.session.playback) { WolfskinTheme {
androidx.compose.foundation.layout.Box(Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) {
ShellSurface(shell) { HomeScreen(shell, snapshot, it) { action -> model.act(action, shell, server, user) } }
PlaybackHandoffVeil(model.session.playback)
}
} } }
rule.mainClock.advanceTimeBy(1000)
rule.mainClock.autoAdvance = false
rule.runOnIdle { model.act(HomeAction.Play(title), shell, server, user) } rule.runOnIdle { model.act(HomeAction.Play(title), shell, server, user) }
awaitModel { navigation.backStack.last() is Destination.Playback } awaitModel { model.session.playback.launch != null }
assertTrue("Preparation must not navigate before the veil", navigation.backStack.single() is Destination.Home)
rule.runOnIdle { androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
rule.mainClock.advanceTimeBy(80)
assertTrue("The first half of the fade must still belong to Home", navigation.backStack.single() is Destination.Home)
rule.mainClock.advanceTimeBy(192)
assertEquals(2, navigation.backStack.size)
val destination = navigation.backStack.last() as Destination.Playback val destination = navigation.backStack.last() as Destination.Playback
assertEquals(id, destination.itemId) assertEquals(id, destination.itemId)
assertEquals(1_136_274L, destination.positionMs) assertEquals(1_136_274L, destination.positionMs)

View file

@ -63,13 +63,18 @@ class SavedPeoplePickerTest {
val session = WolfskinSession() val session = WolfskinSession()
val navigation = NavigationManager() val navigation = NavigationManager()
val model = WolfskinShellViewModel(rule.activity, session, repository, navigation, mockk(relaxed = true), val model = WolfskinShellViewModel(rule.activity, session, repository, navigation, mockk(relaxed = true),
mockk(relaxed = true), libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()) mockk(relaxed = true), libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController(), trailerReturn = mockk(relaxed = true))
try { try {
rule.setContent { rule.setContent {
WolfskinApp(server, users.first(), navigation, UserPreferences(AppPreferences.getDefaultInstance(), null), WolfskinApp(server, users.first(), navigation, UserPreferences(AppPreferences.getDefaultInstance(), null),
viewModel = model, homeContent = { _, _, _, _ -> }) viewModel = model, homeContent = { _, _, _, _ -> })
} }
rule.waitUntil(5_000) { entered.get() && session.state?.people?.size == 3 } rule.waitUntil(5_000) {
// IO completion publishes Compose state on Android's queue, not the test frame clock.
androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications()
org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
entered.get() && session.state?.people?.size == 3
}
users.forEach { rule.onNodeWithText(it.name!!).assertIsDisplayed() } users.forEach { rule.onNodeWithText(it.name!!).assertIsDisplayed() }
rule.runOnIdle { rule.runOnIdle {
assertTrue(session.state!!.pickerVisible) assertTrue(session.state!!.pickerVisible)

View file

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

View file

@ -6,6 +6,7 @@ import androidx.activity.ComponentActivity
import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performKeyInput import androidx.compose.ui.test.performKeyInput
@ -14,6 +15,7 @@ import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.nav.Destination
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.mockk import io.mockk.mockk
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
@ -46,6 +48,7 @@ import java.util.UUID
class TitleAsyncFocusTest { class TitleAsyncFocusTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>() @get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private lateinit var viewModel: WolfskinTitleViewModel private lateinit var viewModel: WolfskinTitleViewModel
private val navigation = NavigationManager()
@After fun dispose() { @After fun dispose() {
if (::viewModel.isInitialized) viewModel.viewModelScope.cancel() if (::viewModel.isInitialized) viewModel.viewModelScope.cancel()
@ -57,9 +60,10 @@ class TitleAsyncFocusTest {
val facade = mockk<TitleReadFacade>() val facade = mockk<TitleReadFacade>()
coEvery { facade.readSaved(id, any()) } returns null coEvery { facade.readSaved(id, any()) } returns null
coEvery { facade.read(id, any(), any()) } coAnswers { ready.await() } coEvery { facade.read(id, any(), any()) } coAnswers { ready.await() }
viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk<TitleFixtureProvider>(), mockk<NavigationManager>(relaxed = true), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk<com.github.damontecres.wholphin.data.ServerRepository>(relaxed = true)) viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk<TitleFixtureProvider>(), navigation, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk<com.github.damontecres.wholphin.data.ServerRepository>(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true))
val person = ShellPerson("justin", "Justin", "J", false) val person = ShellPerson("justin", "Justin", "J", false)
val route = titleRoute(id.toString()) val route = titleRoute(id.toString())
navigation.navigateTo(Destination.Wolfskin(route))
val shell = ShellState(listOf(person), person.key).apply { val shell = ShellState(listOf(person), person.key).apply {
pickerVisible = false pickerVisible = false
pickerLaunch = false pickerLaunch = false
@ -69,9 +73,12 @@ class TitleAsyncFocusTest {
rule.setContent { WolfskinTheme { TitleDestination(route, shell, preferences, viewModel = viewModel) } } rule.setContent { WolfskinTheme { TitleDestination(route, shell, preferences, viewModel = viewModel) } }
rule.waitUntil { viewModel.title?.data?.status == TitleReadStatus.Loading } rule.waitUntil { viewModel.title?.data?.status == TitleReadStatus.Loading }
rule.waitForIdle() rule.waitForIdle()
assertTrue(shell.navFocused) assertFalse("Loading retains destination focus instead of focusing the root nav", shell.navFocused)
rule.onNodeWithTag("title-page").assertIsFocused()
press(Key.DirectionDown) press(Key.DirectionDown)
assertTrue("The initial skeleton cannot receive nav Down", shell.navFocused) assertFalse("Down cannot transfer focus to nav or an initial skeleton", shell.navFocused)
assertEquals("header", viewModel.title!!.row)
assertEquals(0, viewModel.title!!.column)
val source = TitleVersion("first", "Original", "1080p", "", "", "MKV", listOf(TitleTrack(3, "English", true)), emptyList()) val source = TitleVersion("first", "Original", "1080p", "", "", "MKV", listOf(TitleTrack(3, "English", true)), emptyList())
rule.runOnIdle { rule.runOnIdle {
@ -96,9 +103,10 @@ class TitleAsyncFocusTest {
val facade = mockk<TitleReadFacade>() val facade = mockk<TitleReadFacade>()
coEvery { facade.readPersonSaved(id) } returns null coEvery { facade.readPersonSaved(id) } returns null
coEvery { facade.readPerson(id, 0) } coAnswers { reply.await() } coEvery { facade.readPerson(id, 0) } coAnswers { reply.await() }
viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk<TitleFixtureProvider>(), mockk<NavigationManager>(relaxed = true), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk<com.github.damontecres.wholphin.data.ServerRepository>(relaxed = true)) viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk<TitleFixtureProvider>(), navigation, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk<com.github.damontecres.wholphin.data.ServerRepository>(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true))
val viewer = ShellPerson("justin", "Justin", "J", false) val viewer = ShellPerson("justin", "Justin", "J", false)
val route = personRoute(id.toString()) val route = personRoute(id.toString())
navigation.navigateTo(Destination.Wolfskin(route))
val shell = ShellState(listOf(viewer), viewer.key).apply { val shell = ShellState(listOf(viewer), viewer.key).apply {
pickerVisible = false pickerVisible = false
pickerLaunch = false pickerLaunch = false
@ -109,9 +117,12 @@ class TitleAsyncFocusTest {
} } } }
rule.waitUntil { viewModel.title?.data?.status == TitleReadStatus.Loading } rule.waitUntil { viewModel.title?.data?.status == TitleReadStatus.Loading }
rule.waitForIdle() rule.waitForIdle()
assertTrue(shell.navFocused) assertFalse(shell.navFocused)
rule.onNodeWithTag("title-page").assertIsFocused()
press(Key.DirectionDown) press(Key.DirectionDown)
assertTrue(shell.navFocused) assertFalse("Down cannot enter a pending person skeleton", shell.navFocused)
assertEquals("header", viewModel.title!!.row)
assertEquals(0, viewModel.title!!.column)
rule.runOnIdle { rule.runOnIdle {
reply.complete(org.justinvisser.wolfskin.tv.read.PersonRead( reply.complete(org.justinvisser.wolfskin.tv.read.PersonRead(
org.justinvisser.wolfskin.design.TitlePerson(id.toString(), "Actor"), org.justinvisser.wolfskin.design.TitlePerson(id.toString(), "Actor"),
@ -121,7 +132,7 @@ class TitleAsyncFocusTest {
rule.waitUntil(timeoutMillis = 5000) { viewModel.person != null || viewModel.title?.data?.status == TitleReadStatus.Unavailable } rule.waitUntil(timeoutMillis = 5000) { viewModel.person != null || viewModel.title?.data?.status == TitleReadStatus.Unavailable }
assertNotNull("A real person reply must populate the performer destination", viewModel.person) assertNotNull("A real person reply must populate the performer destination", viewModel.person)
rule.waitForIdle() rule.waitForIdle()
assertFalse("The loaded person must transfer nav ownership to its first credit", shell.navFocused) assertFalse("The loaded person retains destination focus on its first credit", shell.navFocused)
assertEquals(0, viewModel.person!!.index) assertEquals(0, viewModel.person!!.index)
press(Key.DirectionRight) press(Key.DirectionRight)
assertEquals(1, viewModel.person!!.index) assertEquals(1, viewModel.person!!.index)
@ -139,9 +150,10 @@ class TitleAsyncFocusTest {
reads.incrementAndGet() reads.incrementAndGet()
throw java.io.IOException("offline") throw java.io.IOException("offline")
} }
viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk<TitleFixtureProvider>(), mockk<NavigationManager>(relaxed = true), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk<com.github.damontecres.wholphin.data.ServerRepository>(relaxed = true)) viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk<TitleFixtureProvider>(), navigation, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk<com.github.damontecres.wholphin.data.ServerRepository>(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true))
val viewer = ShellPerson("justin", "Justin", "J", false) val viewer = ShellPerson("justin", "Justin", "J", false)
val route = titleRoute(id.toString()) val route = titleRoute(id.toString())
navigation.navigateTo(Destination.Wolfskin(route))
val shell = ShellState(listOf(viewer), viewer.key).apply { val shell = ShellState(listOf(viewer), viewer.key).apply {
pickerVisible = false; pickerLaunch = false; enterDestination(route) pickerVisible = false; pickerLaunch = false; enterDestination(route)
} }

View file

@ -24,6 +24,8 @@ import org.json.JSONObject
import org.junit.Before import org.junit.Before
import org.junit.After import org.junit.After
import org.junit.Rule import org.junit.Rule
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.justinvisser.wolfskin.design.LocalTitleBitmaps import org.justinvisser.wolfskin.design.LocalTitleBitmaps
@ -87,7 +89,7 @@ class TitleCaptureTest {
data.cast.forEach { person -> person.portrait?.let { add(Triple(it, 200, 200)) } } data.cast.forEach { person -> person.portrait?.let { add(Triple(it, 200, 200)) } }
data.related.forEach { item -> item.poster?.let { add(Triple(it, 256, 384)) } } data.related.forEach { item -> item.poster?.let { add(Triple(it, 256, 384)) } }
data.episodes.forEach { item -> data.episodes.forEach { item ->
(if (item.isPosterBackdrop) item.poster else item.backdrop)?.let { add(Triple(it, (WolfskinTokens.Card.tileW.value * 2).toInt(), (WolfskinTokens.Card.tileH.value * 2).toInt())) } (if (item.isPosterBackdrop) item.poster else item.backdrop)?.let { add(Triple(it, (WolfskinTokens.Title.episodeCardWidth.value * 2).toInt(), (WolfskinTokens.Title.episodeCardHeight.value * 2).toInt())) }
} }
} }
return requests.distinctBy { it.first }.associate { (path, width, height) -> return requests.distinctBy { it.first }.associate { (path, width, height) ->
@ -170,7 +172,10 @@ class TitleCaptureTest {
@Test fun st044_versions() = capture(44) { state, shell -> open(state, shell, "version") } @Test fun st044_versions() = capture(44) { state, shell -> open(state, shell, "version") }
@Test fun st045_rememberedVersion() = capture(45) { state, _ -> state.versionId = "v2"; option(state, "version") } @Test fun st045_rememberedVersion() = capture(45) { state, _ -> state.versionId = "v2"; option(state, "version") }
@Test fun st046_tracks() = capture(46) { state, shell -> open(state, shell, "tracks") } @Test fun st046_tracks() = capture(46) { state, shell -> open(state, shell, "tracks") }
@Test fun st047_mediaInfo() = capture(47) { state, shell -> open(state, shell, "more") } @Test fun st047_mediaInfo() = capture(47) { state, shell ->
open(state, shell, "menu")
state.clickSheet(0)
}
@Test fun st048_trailerNoApp() = capture(48) { state, _ -> option(state, "trailer"); state.toast = "No YouTube app is available to play this trailer." } @Test fun st048_trailerNoApp() = capture(48) { state, _ -> option(state, "trailer"); state.toast = "No YouTube app is available to play this trailer." }
@Test fun st049_watched() = capture(49, transform = { it.copy(watchTarget = it.watchTarget.copy(watched = true)) }) { state, _ -> option(state, "watched") } @Test fun st049_watched() = capture(49, transform = { it.copy(watchTarget = it.watchTarget.copy(watched = true)) }) { state, _ -> option(state, "watched") }
@Test fun st050_seriesConfirm() = capture(50, "Andor") { state, shell -> open(state, shell, "watched") } @Test fun st050_seriesConfirm() = capture(50, "Andor") { state, shell -> open(state, shell, "watched") }
@ -216,4 +221,33 @@ class TitleCaptureTest {
@Test fun st108_resumingEpisodeRatings() = capture(108, "episode-sex-and-the-country-2001-s4e9", "mindel") @Test fun st108_resumingEpisodeRatings() = capture(108, "episode-sex-and-the-country-2001-s4e9", "mindel")
@Test fun st109_noRatings() = capture(109, "The Amazing Spider-Man") @Test fun st109_noRatings() = capture(109, "The Amazing Spider-Man")
@Test fun spacingCastFirstFocused() = capture(114) { state, _ -> state.focus("cast", 0) } @Test fun spacingCastFirstFocused() = capture(114) { state, _ -> state.focus("cast", 0) }
@Test fun round2Menu() = capture(120) { state, shell -> open(state, shell, "menu") }
@Test fun round2MussoliniResume() = capture(121, "episode-episode-1-2025-s1e1", "shared", transform = { it.copy(episodes = it.episodes.distinctBy { episode -> episode.id }) })
@Test fun round2Episodes() = capture(122, "episode-episode-1-2025-s1e1", "shared", transform = { it.copy(episodes = it.episodes.distinctBy { episode -> episode.id }) }) { state, _ -> state.focus("episodes", 0) }
@Test fun round2NotHeldSeries() = capture(123, "90 Day Fiancé: Before the 90 Days")
@Test fun round2HeldPosterFallback() = capture(124, "The Girl Who Leapt Through Time", transform = { it.copy(item = it.item.copy(backdrop = null, isPosterBackdrop = true, overview = "Short overview.")) })
@Test fun round2NotHeldShortOverview() = capture(125, "The Amazing Spider-Man", transform = { it.copy(item = it.item.copy(backdrop = null, isPosterBackdrop = true, overview = "Short overview.")) })
@Test fun watchedRingBelongsOnlyToFocus() {
lateinit var state: TitleState
capture(126) { current, _ -> state = current; state.watched = false; option(state, "tracks") }
val edges = mutableListOf<List<Int>>()
for ((index, configuration) in listOf(false to false, true to false, false to true, true to true).withIndex()) {
val (watched, focused) = configuration
rule.runOnIdle { state.watched = watched; option(state, if (focused) "watched" else "tracks") }
save(126 + index)
val bounds = rule.onNodeWithTag("title-option-watched").fetchSemanticsNode().boundsInRoot
val file = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "st-${126 + index}.png")
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
val edge = (-8..8).map { offset -> bitmap.getPixel(bounds.left.toInt() + offset, bounds.center.y.toInt()) }
edges.add(edge)
bitmap.recycle()
}
val accent = WolfskinTokens.Color.accent.toArgb()
assertEquals("Unwatched at rest has no ring", 0, edges[0].count { it == accent })
assertEquals("Watched at rest has no ring", 0, edges[1].count { it == accent })
assertEquals("Toggling watched must not change the resting edge", edges[0], edges[1])
assertTrue("Keyboard focus keeps its accent ring", edges[2].any { it == accent })
assertEquals("Watched keeps exactly the same focused ring", edges[2], edges[3])
}
} }

View file

@ -0,0 +1,245 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import android.os.Build
import android.os.Looper
import androidx.activity.ComponentActivity
import androidx.activity.BackEventCompat
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onRoot
import com.github.takahirom.roborazzi.captureRoboImage
import java.io.File
import androidx.lifecycle.viewModelScope
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.ui.NavDisplay
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.mockk
import java.util.UUID
import kotlinx.coroutines.cancel
import org.junit.After
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.PersonRead
import org.justinvisser.wolfskin.tv.read.TitleReadFacade
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** Real NavDisplay and system/predictive Back must pop one title/person entry before the root ladder. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34, 36], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class TitleEntryBackTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private lateinit var viewModel: WolfskinTitleViewModel
private lateinit var conductarr: ConductarrSession
private var homeModel: WolfskinHomeViewModel? = null
private var previousFixtures: Boolean? = null
@After fun dispose() {
if (::viewModel.isInitialized) viewModel.viewModelScope.cancel()
homeModel?.viewModelScope?.cancel()
previousFixtures?.let(ConductarrPreference::updateFixtures)
if (::conductarr.isInitialized) conductarr.close()
}
@Test fun outgoingLoadedTitleCannotConsumeTheNextHomeBack() = verifyOutgoingBack(personPage = false)
@Test fun outgoingLoadedPersonCannotConsumeTheNextHomeBack() = verifyOutgoingBack(personPage = true)
@Test fun predictiveTitleBackPopsOnlyOneEntry() = verifyOutgoingBack(personPage = false, predictive = true)
@Test fun predictivePersonBackPopsOnlyOneEntry() = verifyOutgoingBack(personPage = true, predictive = true)
@Test fun homeTitleBackKeepsTheExactPreOpenViewport() = verifyHomeViewport(reduced = false)
@Test fun reducedMotionHomeTitleBackKeepsTheExactPreOpenViewport() = verifyHomeViewport(reduced = true)
private fun verifyHomeViewport(reduced: Boolean) {
val navigation = NavigationManager()
val viewer = ShellPerson("justin", "Justin", "J", true)
val shell = ShellState(listOf(viewer), viewer.key).apply { pickerVisible = false; pickerLaunch = false }
val snapshot = HomeFixture(rule.activity).snapshot(viewer.key)
conductarr = ConductarrSession(MemoryCacheStore())
previousFixtures = ConductarrPreference.fixtures
ConductarrPreference.updateFixtures(true)
val server = com.github.damontecres.wholphin.data.model.JellyfinServer(UUID.fromString("00000000-0000-0000-0000-000000000001"), "Fixture", "https://example.invalid", null)
val user = com.github.damontecres.wholphin.data.model.JellyfinUser(id = UUID.fromString("00000000-0000-0000-0000-000000000002"), name = "Justin", serverId = server.id, accessToken = null)
val source = WolfskinHomeViewModel(rule.activity, mockk(relaxed = true), mockk(), navigation, WolfskinSeeAllLists(), conductarr, WolfskinSession())
homeModel = source
viewModel = WolfskinTitleViewModel(rule.activity, mockk(), TitleFixtureProvider(rule.activity), navigation, conductarr,
mockk(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true))
val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null)
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { WolfskinTheme {
val onBack = rememberWolfskinBackHandler(shell, navigation)
NavDisplay(backStack = navigation.backStack, onBack = onBack,
entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator()),
entryProvider = { destination -> NavEntry(destination) {
val active = navigation.backStack.lastOrNull() == destination
val route = (destination as? Destination.Wolfskin)?.route ?: "home"
LaunchedEffect(destination, active) { if (active) shell.enterDestination(route) }
ScreenEntry(shell, route, active) {
if (destination is Destination.Home) ShellSurface(shell) { modifier ->
HomeDestination(shell, server, user, modifier, source)
} else TitleDestination(route, shell, preferences, viewModel = viewModel, fixtures = true)
}
} })
} } }
rule.waitUntil(30_000) {
Shadows.shadowOf(Looper.getMainLooper()).idle()
source.snapshot.rows.any { it.id == "cw" } && !shell.navFocused
}
rule.onRoot().performKeyInput { pressKey(Key.DirectionDown) }
rule.mainClock.advanceTimeBy(1000)
rule.waitForIdle()
val home = shell.rememberedView("home")
val tag = "home-card:${snapshot.rows[home.row].id}:0"
val before = home.listState.let { it.firstVisibleItemIndex to it.firstVisibleItemScrollOffset }
val bounds = rule.onNodeWithTag(tag).fetchSemanticsNode().boundsInRoot
val captures = File(System.getProperty("wolfskin.captureDirectory"), "home-title-back-${if (reduced) "reduced" else "animated"}").apply { mkdirs() }
rule.onRoot().captureRoboImage(File(captures, "sdk${Build.VERSION.SDK_INT}-before.png").absolutePath)
rule.onRoot().performKeyInput { pressKey(Key.DirectionCenter) }
rule.waitUntil(30_000) {
Shadows.shadowOf(Looper.getMainLooper()).idle()
viewModel.title?.data?.status == TitleReadStatus.Ready
}
rule.mainClock.advanceTimeBy(1000)
dispatchBack(predictive = false)
rule.mainClock.advanceTimeBy(1000)
rule.waitForIdle()
rule.onRoot().captureRoboImage(File(captures, "sdk${Build.VERSION.SDK_INT}-after.png").absolutePath)
assertEquals(before, home.listState.let { it.firstVisibleItemIndex to it.firstVisibleItemScrollOffset })
assertEquals(bounds, rule.onNodeWithTag(tag).fetchSemanticsNode().boundsInRoot)
}
@Test fun nativeBackKeyWaitsForSystemCompletionBeforePoppingTheTitle() {
val (navigation, shell) = mountTitle(personPage = false)
val before = navigation.backStack.toList()
rule.runOnIdle { rule.activity.dispatchKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_DOWN, android.view.KeyEvent.KEYCODE_BACK)) }
assertEquals("Compose must not pop on native Back key-down before the system completes Back", before, navigation.backStack.toList())
// Predictive completion is a platform callback, not a raw key-up delivered to Compose.
dispatchBack(predictive = true)
rule.mainClock.advanceTimeBy(400)
assertEquals(listOf(Destination.Home()), navigation.backStack.toList())
assertFalse(rule.activity.isFinishing)
assertFalse(shell.navFocused)
val captures = File(System.getProperty("wolfskin.captureDirectory"), "system-back").apply { mkdirs() }
rule.onRoot().captureRoboImage(File(captures, "sdk${Build.VERSION.SDK_INT}-title-home.png").absolutePath)
}
@Test fun immediateSecondSystemBackUsesHomeBeforeExitRecomposition() {
val (navigation, shell) = mountTitle(personPage = false)
rule.runOnIdle {
rule.activity.onBackPressedDispatcher.onBackPressed()
assertEquals(listOf(Destination.Home()), navigation.backStack.toList())
rule.activity.onBackPressedDispatcher.onBackPressed()
assertTrue("The next Back belongs to Home even before Compose retires the title handler", shell.navFocused)
assertFalse(rule.activity.isFinishing)
}
rule.mainClock.advanceTimeBy(16)
assertFalse(rule.activity.isFinishing)
dispatchBack(predictive = false)
rule.mainClock.advanceTimeBy(16)
assertTrue(rule.activity.isFinishing)
}
private fun mountTitle(personPage: Boolean): Pair<NavigationManager, ShellState> {
val activity = rule.activity
val navigation = NavigationManager()
val viewer = ShellPerson("justin", "Justin", "J", false)
val shell = ShellState(listOf(viewer), viewer.key) {
when (it) {
ShellAction.Exit -> navigation.requestExit()
ShellAction.Pop -> navigation.goBack()
else -> Unit
}
}.apply { pickerVisible = false; pickerLaunch = false; navFocused = false }
val id = UUID.fromString("00000000-0000-0000-0000-000000000031")
val route = if (personPage) personRoute(id.toString()) else titleRoute(id.toString())
val destination = Destination.Wolfskin(route)
val facade = mockk<TitleReadFacade>()
if (personPage) {
coEvery { facade.readPersonSaved(id) } returns null
coEvery { facade.readPerson(id, 0) } returns PersonRead(TitlePerson(id.toString(), "Outgoing actor"),
listOf(TitleItem("credit", "Credit", "movie")))
} else {
coEvery { facade.readSaved(id, any()) } returns null
coEvery { facade.read(id, any(), any()) } returns TitlePageData(TitleItem(id.toString(), "Outgoing film", "movie", held = true))
}
conductarr = ConductarrSession(MemoryCacheStore())
viewModel = WolfskinTitleViewModel(activity, facade, mockk<TitleFixtureProvider>(), navigation, conductarr,
mockk<ServerRepository>(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true))
shell.enterDestination(route)
navigation.navigateTo(destination)
val home = HomeSnapshot(listOf(HomeRow("hero", HomeRowKind.Hero, cards = listOf(HomeTitle("home-film", "Home film", "movie")))))
val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null)
rule.setContent {
WolfskinTheme {
val onBack = rememberWolfskinBackHandler(shell, navigation)
NavDisplay(backStack = navigation.backStack, onBack = onBack, entryProvider = { entry ->
NavEntry(entry) {
val active = navigation.backStack.lastOrNull() == entry
val entryRoute = if (entry is Destination.Home) "home" else route
LaunchedEffect(entry, active) { if (active) shell.enterDestination(entryRoute) }
ScreenEntry(shell, entryRoute, active) {
if (entry is Destination.Home) ShellSurface(shell) { modifier -> HomeScreen(shell, home, modifier) {} }
else TitleDestination(route, shell, preferences, viewModel = viewModel, fixtures = false)
}
}
})
}
}
rule.waitUntil(5_000) {
Snapshot.sendApplyNotifications()
Shadows.shadowOf(Looper.getMainLooper()).idle()
(if (personPage) viewModel.person != null else viewModel.title?.data?.status == TitleReadStatus.Ready) && !shell.navFocused
}
rule.waitForIdle()
return navigation to shell
}
private fun verifyOutgoingBack(personPage: Boolean, predictive: Boolean = false) {
val activity = rule.activity
val (navigation, shell) = mountTitle(personPage)
dispatchBack(predictive)
rule.mainClock.advanceTimeBy(400)
assertFalse("One system Back from a title/person must never finish the task", activity.isFinishing)
rule.waitForIdle()
assertEquals(listOf(Destination.Home()), navigation.backStack.toList())
assertFalse(shell.navFocused)
dispatchBack(predictive)
rule.mainClock.advanceTimeBy(16)
assertTrue("The incoming Home must receive Back while the outgoing entry is retained", shell.navFocused)
assertFalse("A second Back must not finish the task through the outgoing entry", activity.isFinishing)
assertEquals(listOf(Destination.Home()), navigation.backStack.toList())
dispatchBack(predictive)
rule.mainClock.advanceTimeBy(16)
assertTrue("Only the following Back from Home nav should exit", activity.isFinishing)
}
private fun dispatchBack(predictive: Boolean) {
rule.runOnIdle {
val dispatcher = rule.activity.onBackPressedDispatcher
if (predictive) {
dispatcher.dispatchOnBackStarted(BackEventCompat(0f, 0f, 0f, BackEventCompat.EDGE_LEFT))
dispatcher.dispatchOnBackProgressed(BackEventCompat(100f, 0f, .5f, BackEventCompat.EDGE_LEFT))
}
dispatcher.onBackPressed()
}
}
}

View file

@ -12,11 +12,9 @@ import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performMouseInput import androidx.compose.ui.test.performMouseInput
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.hasClickAction import androidx.compose.ui.test.hasClickAction
import androidx.compose.ui.test.hasText import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.text.TextLayoutResult
import org.justinvisser.wolfskin.design.WolfskinTokens import org.justinvisser.wolfskin.design.WolfskinTokens
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
@ -36,7 +34,6 @@ import org.justinvisser.wolfskin.design.TitleSurface
import org.justinvisser.wolfskin.design.WolfskinTheme import org.justinvisser.wolfskin.design.WolfskinTheme
import org.robolectric.RobolectricTestRunner import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** Approved title-entry suffixes replay Android keys through the rendered title and shared shell. */ /** Approved title-entry suffixes replay Android keys through the rendered title and shared shell. */
@RunWith(RobolectricTestRunner::class) @RunWith(RobolectricTestRunner::class)
@ -147,7 +144,7 @@ class TitleKeysTest {
assertEquals("Version", state.options[state.column].label) assertEquals("Version", state.options[state.column].label)
press(Key.DirectionCenter) press(Key.DirectionCenter)
assertEquals(1, state.sheetIndex) assertEquals(1, state.sheetIndex)
press(Key.Back) press(Key.Escape)
assertNull(state.sheet) assertNull(state.sheet)
assertEquals("Version", state.options[state.column].label) assertEquals("Version", state.options[state.column].label)
assertFalse(shell.navFocused) assertFalse(shell.navFocused)
@ -164,7 +161,7 @@ class TitleKeysTest {
assertEquals(2, state.subtitleIndex) assertEquals(2, state.subtitleIndex)
press(Key.DirectionUp); press(Key.DirectionCenter) press(Key.DirectionUp); press(Key.DirectionCenter)
assertEquals(-1, state.subtitleIndex) assertEquals(-1, state.subtitleIndex)
press(Key.Back) press(Key.Escape)
assertNull(state.sheet) assertNull(state.sheet)
assertEquals("tracks", state.options[state.column].id) assertEquals("tracks", state.options[state.column].id)
} }
@ -284,24 +281,19 @@ class TitleKeysTest {
press(Key.DirectionDown) press(Key.DirectionDown)
assertEquals(expected, state.row) assertEquals(expected, state.row)
} }
@Test fun seriesOptionsKeepTheCompleteHouseholdActionLabel() {
render("Andor")
val layouts = mutableListOf<TextLayoutResult>()
rule.onNodeWithText("Add for someone", useUnmergedTree = true)
.performSemanticsAction(SemanticsActions.GetTextLayoutResult) { action -> action(layouts) }
val layout = layouts.single()
assertFalse("The final pill must not shrink to its icon or a clipped label", layout.didOverflowWidth)
assertEquals(1, layout.lineCount)
}
@Test fun anInitialLoadNeverExposesStaleCastBelowItsSkeleton() { @Test fun anInitialLoadNeverExposesStaleCastBelowItsSkeleton() {
render(transform = { it.copy(status = TitleReadStatus.Loading) }) render(transform = { it.copy(status = TitleReadStatus.Loading) })
rule.onNodeWithText("Cast").assertDoesNotExist() rule.onNodeWithText("Cast").assertDoesNotExist()
press(Key.DirectionDown) press(Key.DirectionDown)
assertTrue(shell.navFocused) assertFalse(shell.navFocused)
rule.onNodeWithTag("title-page").assertIsFocused()
assertEquals("header", state.row) assertEquals("header", state.row)
assertEquals(0, state.column)
assertFalse(shell.pickerVisible)
rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() } rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() }
assertEquals(listOf(TitleAction.Pop), effects) assertEquals(listOf(TitleAction.Pop), effects)
assertFalse(shell.pickerVisible)
} }
@ -315,26 +307,6 @@ class TitleKeysTest {
assertEquals(WolfskinTokens.Title.factsGap.value * 2, genre.left - year.right, 1f) assertEquals(WolfskinTokens.Title.factsGap.value * 2, genre.left - year.right, 1f)
} }
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Test fun mediaInfoRowsPreserveTheirDeclaredLineBoxes() {
render()
rule.runOnIdle { state.focus("header", state.options.indexOfFirst { it.id == "more" }) }
press(Key.DirectionCenter)
val container = rule.onNodeWithText("Container", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
val size = rule.onNodeWithText("Size", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
val expectedPitch = (WolfskinTokens.Title.infoKey.lineHeight.value + WolfskinTokens.Title.infoRowGap.value) * 2
assertEquals("Stacked facts retain the CSS line-box rhythm", expectedPitch, size.top - container.top, 1f)
val lastSubtitle = rule.onNodeWithText("Dutch", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
assertEquals("Fourteen stacked lines do not accumulate native pixel rounding", 526.2f, lastSubtitle.top - container.top, 1f)
}
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Test fun filmHeaderKeepsItsFixedCssStack() {
render()
assertEquals(358f, rule.onNodeWithTag("title-facts").fetchSemanticsNode().boundsInRoot.top, 1f)
assertEquals(410f, rule.onNodeWithTag("title-overview").fetchSemanticsNode().boundsInRoot.top, 1f)
assertEquals(553f, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot.top, 1f)
}
@Test fun everyLongTrackChoiceAndDoneRemainInsideTheVisibleSheet() { @Test fun everyLongTrackChoiceAndDoneRemainInsideTheVisibleSheet() {

View file

@ -1,6 +1,9 @@
package org.justinvisser.wolfskin.tv package org.justinvisser.wolfskin.tv
import android.app.Application import android.app.Application
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import org.junit.Rule
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
@ -35,6 +38,7 @@ import java.util.UUID
@Config(sdk = [34], application = Application::class) @Config(sdk = [34], application = Application::class)
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
class TitlePlaybackNavigationTest { class TitlePlaybackNavigationTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private val seriesId = UUID.fromString("00000000-0000-0000-0000-000000000001") private val seriesId = UUID.fromString("00000000-0000-0000-0000-000000000001")
private val firstId = UUID.fromString("00000000-0000-0000-0000-000000000002") private val firstId = UUID.fromString("00000000-0000-0000-0000-000000000002")
private val secondId = UUID.fromString("00000000-0000-0000-0000-000000000003") private val secondId = UUID.fromString("00000000-0000-0000-0000-000000000003")
@ -55,7 +59,7 @@ class TitlePlaybackNavigationTest {
episodes = listOf(first, second), related = listOf(series), cast = listOf(TitlePerson("actor", "Actor"))) episodes = listOf(first, second), related = listOf(series), cast = listOf(TitlePerson("actor", "Actor")))
} }
val session = ConductarrSession(MemoryCacheStore()) val session = ConductarrSession(MemoryCacheStore())
val model = WolfskinTitleViewModel(RuntimeEnvironment.getApplication(), facade, mockk(), navigation, session, mockk<ServerRepository>(relaxed = true)) val model = WolfskinTitleViewModel(RuntimeEnvironment.getApplication(), facade, mockk(), navigation, session, mockk<ServerRepository>(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true))
fun open(id: UUID) = model.load(titleRoute(id.toString()), person, preferences, fixtures = false, force = true) fun open(id: UUID) = model.load(titleRoute(id.toString()), person, preferences, fixtures = false, force = true)
try { try {
navigation.navigateTo(Destination.Wolfskin(titleRoute(seriesId.toString()))) navigation.navigateTo(Destination.Wolfskin(titleRoute(seriesId.toString())))
@ -109,25 +113,37 @@ class TitlePlaybackNavigationTest {
coEvery { facade.preparePlayback(firstId) } returns BaseItemDto(id = firstId, type = BaseItemKind.EPISODE, coEvery { facade.preparePlayback(firstId) } returns BaseItemDto(id = firstId, type = BaseItemKind.EPISODE,
userData = playbackUserData(firstId, 960_780_000L, playCount = 1)) userData = playbackUserData(firstId, 960_780_000L, playCount = 1))
val session = ConductarrSession(MemoryCacheStore()) val session = ConductarrSession(MemoryCacheStore())
val model = WolfskinTitleViewModel(RuntimeEnvironment.getApplication(), facade, mockk(), navigation, session, mockk<ServerRepository>(relaxed = true)) val model = WolfskinTitleViewModel(RuntimeEnvironment.getApplication(), facade, mockk(), navigation, session, mockk<ServerRepository>(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true))
rule.mainClock.autoAdvance = false
rule.setContent { model.title?.let { PlaybackHandoffVeil(it.playbackStart) } }
fun finishHandoff() {
rule.waitForIdle()
assertFalse("Preparation alone cannot navigate before black", navigation.backStack.last() is Destination.Playback)
rule.mainClock.advanceTimeBy(WolfskinTokens.Motion.base.toLong() + 48)
rule.waitForIdle()
}
try { try {
model.load(titleRoute(seriesId.toString()), person, preferences, fixtures = false) model.load(titleRoute(seriesId.toString()), person, preferences, fixtures = false)
advanceUntilIdle() advanceUntilIdle()
model.title!!.click("header", 0) model.title!!.click("header", 0)
advanceUntilIdle() advanceUntilIdle()
finishHandoff()
assertEquals(Destination.Playback(secondId, 1_136_274L), navigation.backStack.last()) assertEquals(Destination.Playback(secondId, 1_136_274L), navigation.backStack.last())
navigation.goBack() navigation.goBack()
model.title!!.click("header", model.title!!.options.indexOfFirst { it.id == "restart" }) model.title!!.click("header", model.title!!.options.indexOfFirst { it.id == "restart" })
advanceUntilIdle() advanceUntilIdle()
finishHandoff()
assertEquals(Destination.Playback(firstId, 0L), navigation.backStack.last()) assertEquals(Destination.Playback(firstId, 0L), navigation.backStack.last())
navigation.goBack() navigation.goBack()
model.load(titleRoute(firstId.toString()), person, preferences, fixtures = false) model.load(titleRoute(firstId.toString()), person, preferences, fixtures = false)
advanceUntilIdle() advanceUntilIdle()
model.title!!.click("header", 0) model.title!!.click("header", 0)
advanceUntilIdle() advanceUntilIdle()
finishHandoff()
assertEquals(Destination.Playback(firstId, 96_078L), navigation.backStack.last()) assertEquals(Destination.Playback(firstId, 96_078L), navigation.backStack.last())
coVerify(exactly = 1) { facade.preparePlayback(seriesId) } coVerify(exactly = 1) { facade.preparePlayback(seriesId) }
} finally { } finally {
rule.mainClock.autoAdvance = true
model.viewModelScope.cancel() model.viewModelScope.cancel()
session.close() session.close()
Dispatchers.resetMain() Dispatchers.resetMain()

View file

@ -0,0 +1,481 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import android.graphics.BitmapFactory
import android.os.Looper
import androidx.activity.ComponentActivity
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.unit.dp
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 com.github.takahirom.roborazzi.captureRoboImage
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.cancel
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.json.JSONObject
import org.junit.After
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.TitleReadFacade
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import java.io.File
import java.util.UUID
/** Native title transitions preserve focus, stream identity and immediate cached content. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class TitlePolishTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private var model: WolfskinTitleViewModel? = null
private var session: ConductarrSession? = null
private lateinit var state: TitleState
private lateinit var shell: ShellState
private val reduced = mutableStateOf(false)
private val entryActive = mutableStateOf(true)
private val samples = mutableListOf<String>()
private var bitmaps: Map<String, ImageBitmap> = emptyMap()
private val person = ShellPerson("justin", "Justin", "J", false)
private val id = UUID.fromString("00000000-0000-0000-0000-000000000091")
private val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null)
@After fun dispose() { model?.viewModelScope?.cancel(); session?.close() }
private fun fixture() = TitleFixture(rule.activity.assets.open("wolfskin-titles.json").bufferedReader().use { JSONObject(it.readText()) })
private fun prepareShell() {
shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; pickerLaunch = false; enterDestination(titleRoute(id.toString())); navFocused = false }
}
private fun render(data: TitlePageData = fixture().title("Locke", "justin"), reducedMotion: Boolean = false) {
reduced.value = reducedMotion
prepareShell()
state = TitleState(data, person) { }
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced.value) { WolfskinTheme {
TitleSurface(state, shell, Modifier.requiredSize(960.dp, 540.dp))
} } }
rule.waitForIdle()
rule.mainClock.advanceTimeBy(600)
rule.mainClock.autoAdvance = false
}
private fun mountModel(facade: TitleReadFacade, data: TitlePageData, reducedMotion: Boolean) : NavigationManager {
reduced.value = reducedMotion
prepareShell()
coEvery { facade.readSaved(id, any()) } returns null
coEvery { facade.read(id, any(), any()) } returns data
val navigation = NavigationManager().apply { navigateTo(Destination.Wolfskin(titleRoute(id.toString()))) }
session = ConductarrSession(MemoryCacheStore())
model = WolfskinTitleViewModel(rule.activity, facade, mockk(), navigation, session!!,
mockk<ServerRepository>(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true))
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced.value, LocalTitleBitmaps provides bitmaps) { WolfskinTheme {
val route = titleRoute(id.toString())
ScreenEntry(shell, route, entryActive.value && navigation.backStack.lastOrNull() == Destination.Wolfskin(route)) {
TitleDestination(route, shell, preferences, Modifier.requiredSize(960.dp, 540.dp), model!!, fixtures = false)
}
} } }
rule.waitUntil(5000) { shadowOf(Looper.getMainLooper()).idle(); model!!.title?.data?.status == TitleReadStatus.Ready }
state = model!!.title!!
rule.waitForIdle()
rule.mainClock.advanceTimeBy(600)
rule.mainClock.autoAdvance = false
return navigation
}
private fun tick() { rule.mainClock.advanceTimeBy(16); rule.waitForIdle() }
private fun path(idea: String, name: String, index: Int): File = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")),
"ui-polish/$idea/${if (reduced.value) "reduced-" else ""}$name-${index.toString().padStart(2, '0')}.png").also { requireNotNull(it.parentFile).mkdirs() }
private fun frame(idea: String, name: String, index: Int, sample: (android.graphics.Bitmap) -> Int): Int {
val file = path(idea, name, index)
rule.onRoot().captureRoboImage(file.absolutePath)
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
return try { sample(bitmap).also { samples.add("$name,${index * 16},$it") } } finally { bitmap.recycle() }
}
private fun brightness(bitmap: android.graphics.Bitmap, x: Int, y: Int): Int = bitmap.getPixel(x, y).let {
android.graphics.Color.red(it) + android.graphics.Color.green(it) + android.graphics.Color.blue(it)
}
private fun saveSamples(idea: String) {
File(path(idea, "samples", 0).parentFile, "${if (reduced.value) "reduced-" else ""}${samples.first().substringBefore(',')}-samples.csv")
.writeText("phase,timeMs,value\n" + samples.joinToString("\n"))
}
private fun monotonic(values: List<Int>) {
assertNotEquals("Motion changes visible pixels", values.first(), values.last())
val direction = if (values.last() > values.first()) 1 else -1
values.zipWithNext().forEach { (before, after) -> assertTrue("No reversal: $values", (after - before) * direction >= -1) }
}
@Test fun versionSheetFadesWithScrimAndReturnsFocusOnce() = versionSheet(false)
@Test fun reducedVersionSheetFadesWithScrimAndReturnsFocusOnce() = versionSheet(true)
private fun versionSheet(reducedMotion: Boolean) {
render(fixture().title("Locke", "justin"), reducedMotion)
rule.runOnIdle { state.focus("header", state.options.indexOfFirst { it.id == "version" }) }
rule.onRoot().performKeyInput { pressKey(androidx.compose.ui.input.key.Key.DirectionCenter) }
val duration = if (reducedMotion) WolfskinTokens.Motion.reducedFade else WolfskinTokens.Motion.sheetIn
val scrim = mutableListOf<Int>()
val panel = mutableListOf<Int>()
repeat(duration / 16 + 3) { index ->
frame("P06", "title-version", index) { bitmap ->
scrim.add(brightness(bitmap, 400, 900)); brightness(bitmap, 1900, 900).also { panel.add(it) }
}
tick()
}
monotonic(scrim); monotonic(panel)
val opener = state.column
rule.onRoot().performKeyInput { pressKey(androidx.compose.ui.input.key.Key.Escape) }
val owner = hasTestTag("title-page") or hasTestTag("title-option-version")
val closing = mutableListOf<Int>()
repeat((if (reducedMotion) WolfskinTokens.Motion.reducedFade else WolfskinTokens.Motion.sheetOut) / 16 + 3) { index ->
closing.add(frame("P06", "title-version-out", index) { brightness(it, 1900, 900) })
if (index == 2) {
rule.waitForIdle()
rule.runOnIdle { }
rule.onNode(owner and isFocused(), useUnmergedTree = true).assertExists()
rule.onNodeWithTag("title-sheet", useUnmergedTree = true)
.assert(SemanticsMatcher.keyNotDefined(androidx.compose.ui.semantics.SemanticsProperties.Focused))
rule.onAllNodes(hasAnyAncestor(hasTestTag("title-sheet")) and
SemanticsMatcher.keyIsDefined(androidx.compose.ui.semantics.SemanticsProperties.Focused), useUnmergedTree = true).assertCountEquals(0)
}
assertNull(state.sheet); assertEquals(opener, state.column); assertFalse(shell.navFocused)
tick()
}
monotonic(closing)
rule.onNodeWithTag("title-sheet").assertDoesNotExist()
rule.onNode(owner and isFocused(), useUnmergedTree = true).assertExists()
saveSamples("P06")
}
private fun pendingPlayback(reducedMotion: Boolean): Triple<TitleReadFacade, CompletableDeferred<BaseItemDto?>, NavigationManager> {
val facade = mockk<TitleReadFacade>()
val prepared = CompletableDeferred<BaseItemDto?>()
coEvery { facade.preparePlayback(id) } coAnswers { prepared.await() }
val data = fixture().title("Locke", "justin")
val target = data.item.copy(id = id.toString())
val navigation = mountModel(facade, data.copy(item = target, playTarget = target, watchTarget = target,
related = listOf(TitleItem("other", "Other title", "movie"))), reducedMotion)
return Triple(facade, prepared, navigation)
}
@Test fun playSaysStartingThenFadesToBlackBeforeThePlayer() = playback(false)
@Test fun reducedPlaySaysStartingThenFadesToBlackBeforeThePlayer() = playback(true)
private fun playback(reducedMotion: Boolean) {
val (facade, prepared, navigation) = pendingPlayback(reducedMotion)
val resting = rule.onNodeWithTag("title-option-play").fetchSemanticsNode().boundsInRoot
rule.runOnIdle { state.click("header", 0) }
repeat(13) { index -> frame("E04", "title-start", index) { brightness(it, 100, 900) }; tick() }
rule.onNodeWithText("Starting…").assertIsDisplayed()
assertEquals(resting.width, rule.onNodeWithTag("title-option-play").fetchSemanticsNode().boundsInRoot.width, .1f)
rule.runOnIdle { state.click("header", 0) }
coVerify(exactly = 1) { facade.preparePlayback(id) }
rule.runOnIdle { prepared.complete(BaseItemDto(id = id, type = BaseItemKind.MOVIE)) }
rule.waitUntil(5000) { shadowOf(Looper.getMainLooper()).idle(); state.playbackStart.launch != null }
assertFalse(navigation.backStack.last() is Destination.Playback)
val values = mutableListOf<Int>()
val duration = if (reducedMotion) WolfskinTokens.Motion.reducedFade else WolfskinTokens.Motion.base
repeat(duration / 16 + 3) { index ->
values.add(frame("E04", "title-veil", index) { brightness(it, 100, 900) })
if (index * 16 < duration) assertFalse(navigation.backStack.last() is Destination.Playback)
tick()
}
assertEquals(Destination.Playback(id, 0L), navigation.backStack.last())
assertEquals(1, navigation.backStack.count { it is Destination.Playback })
assertTrue("The handoff reaches black before navigation", values.contains(0))
assertNull(state.playbackStart.starting)
rule.runOnIdle { navigation.goBack() }
coEvery { facade.preparePlayback(id) } throws IllegalStateException("offline")
rule.runOnIdle { state.click("header", 0) }
rule.waitUntil(5000) { shadowOf(Looper.getMainLooper()).idle(); state.toast == RecoveryMessage.PlaybackUnavailable }
repeat(14) { tick() }
rule.onNodeWithText("Starting…").assertDoesNotExist()
rule.onNodeWithText(RecoveryMessage.PlaybackUnavailable).assertIsDisplayed()
assertFalse(navigation.backStack.last() is Destination.Playback)
saveSamples("E04")
}
@Test fun progressFillsToTheNewPointAfterPlayback() = progress(false)
@Test fun reducedProgressSnapsToTheNewPointAfterPlayback() = progress(true)
@Test fun lateRuntimeMetadataAnimatesAnExistingResumeTrack() = progress(false, runtimeKnown = false)
@Test fun reducedLateRuntimeMetadataSnapsAnExistingResumeTrack() = progress(true, runtimeKnown = false)
private fun progress(reducedMotion: Boolean, runtimeKnown: Boolean = true) {
val data = fixture().title("Locke", "justin")
val target = data.item.copy(runtimeSeconds = if (runtimeKnown) 1000 else 0, resumeSeconds = 134)
val from = if (runtimeKnown) .134f else 0f
val phase = if (runtimeKnown) "title-progress" else "title-progress-late-runtime"
render(data.copy(playTarget = target), reducedMotion)
val bounds = rule.onNodeWithTag("title-progress").fetchSemanticsNode().boundsInRoot
val width = (WolfskinTokens.Title.progressWidth.value * 2).toInt()
fun edge(bitmap: android.graphics.Bitmap): Int = (0 until width).lastOrNull { x ->
bitmap.getPixel(bounds.left.toInt() + x, bounds.center.y.toInt()) == WolfskinTokens.Color.accent.toArgb()
}?.plus(1) ?: 0
val initial = frame("E06", "$phase-initial", 0, ::edge)
assertEquals(width * from, initial.toFloat(), 1f)
rule.runOnIdle { state.update(state.data.copy(playTarget = target.copy(runtimeSeconds = 1000, resumeSeconds = 419))) }
val values = List(23) { index ->
frame("E06", phase, index, ::edge).also {
if (index == 1) rule.onNodeWithText("10 min left").assertIsDisplayed()
tick()
}
}
assertEquals(width * .419f, values.last().toFloat(), 1f)
if (reducedMotion) assertEquals(values.last(), values[1]) else {
monotonic(values)
assertEquals(width * (from + .419f) / 2f, values[11].toFloat(), width * .06f)
}
rule.onNodeWithText("10 min left").assertIsDisplayed()
rule.runOnIdle { state.update(state.data.copy(playTarget = target.copy(id = "other", runtimeSeconds = 1000, resumeSeconds = 800))) }
tick()
assertEquals(width * .8f, frame("E06", "title-new-target", 0, ::edge).toFloat(), 1f)
saveSamples("E06")
}
@Test fun cardProgressFillsAfterPlaybackWithoutAnimatingItsFirstPaint() = cardProgress(false)
@Test fun reducedCardProgressSnapsAfterPlayback() = cardProgress(true)
private fun cardProgress(reducedMotion: Boolean) {
reduced.value = reducedMotion
val title = HomeTitle("card", "Card", "movie", runtimeSeconds = 1000, resumeSeconds = 134.0, held = true)
val row = mutableStateOf(HomeRow("resume", HomeRowKind.Shelf, cards = listOf(title)))
val memory = HomeViewMemory()
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced.value) { WolfskinTheme {
Box(Modifier.requiredSize(960.dp, 540.dp).background(WolfskinTokens.Color.bg)) { HomeShelf(row.value, memory, false, 0, emptyList(), {}, {}) }
} } }
rule.waitForIdle(); rule.mainClock.advanceTimeBy(600); rule.mainClock.autoAdvance = false
val bounds = rule.onNodeWithTag("home-card:resume:0").fetchSemanticsNode().boundsInRoot
val y = (bounds.bottom - (WolfskinTokens.CardMetadata.bottom.value + WolfskinTokens.CardMetadata.resumeLine.value / 2) * 2).toInt()
fun edge(bitmap: android.graphics.Bitmap): Int = (0 until bounds.width.toInt()).lastOrNull { x ->
bitmap.getPixel(bounds.left.toInt() + x, y) == WolfskinTokens.Color.accent.toArgb()
}?.plus(1) ?: 0
assertEquals(bounds.width * .134f, frame("E06", "card-progress-initial", 0, ::edge).toFloat(), 1f)
rule.runOnIdle { row.value = row.value.copy(cards = listOf(title.copy(resumeSeconds = 419.0))) }
val values = List(23) { index -> frame("E06", "card-progress", index, ::edge).also { tick() } }
assertEquals(bounds.width * .419f, values.last().toFloat(), 1f)
if (reducedMotion) assertEquals(values.last(), values[1]) else monotonic(values)
saveSamples("E06")
}
@Test fun seasonCapsuleGlidesAndCachedSeasonSkipsSkeleton() = seasons(false)
@Test fun reducedSeasonCapsuleSnapsAndCachedSeasonSkipsSkeleton() = seasons(true)
private fun seasons(reducedMotion: Boolean) {
val facade = mockk<TitleReadFacade>()
val series = TitleItem(id.toString(), "Series", "tv", held = true, seasons = 6)
fun image(color: Int) = android.graphics.Bitmap.createBitmap(2, 2, android.graphics.Bitmap.Config.ARGB_8888)
.apply { eraseColor(color) }.asImageBitmap()
bitmaps = mapOf("cache:fifth" to image(android.graphics.Color.RED), "cache:sixth" to image(android.graphics.Color.GREEN))
val fifth = TitleItem("fifth", "Cached episode", "episode", season = 5, episode = 1, backdrop = "cache:fifth")
val sixth = fifth.copy(id = "sixth", name = "New episode", season = 6, backdrop = "cache:sixth")
val refresh = CompletableDeferred<TitlePageData>()
val data = TitlePageData(series, selectedSeason = 5, episodes = listOf(fifth))
val navigation = mountModel(facade, data, reducedMotion)
coEvery { facade.read(id, 6, any()) } returns data.copy(selectedSeason = 6, episodes = listOf(sixth))
coEvery { facade.read(id, 5, any()) } coAnswers { refresh.await() }
rule.runOnIdle { state.focus("seasons", 4) }
repeat(25) { tick() }
rule.runOnIdle { state.click("seasons", 5); state.focus("seasons", 0) }
val row = rule.onNode(hasText("Season 6") and hasClickAction()).fetchSemanticsNode().boundsInRoot
fun left(bitmap: android.graphics.Bitmap): Int = (0 until bitmap.width).firstOrNull { x ->
val pixel = bitmap.getPixel(x, (row.top + 4).toInt())
android.graphics.Color.red(pixel) > 180 && android.graphics.Color.green(pixel) < 210 && android.graphics.Color.blue(pixel) > 230
} ?: -1
val values = List(23) { index -> frame("E07", "season", index, ::left).also { tick() } }
assertTrue(values.all { it >= 0 })
if (reducedMotion) assertEquals(values.last(), values[1]) else monotonic(values)
values.forEach { assertTrue("No capsule overshoot", it <= values.last() + 1) }
rule.runOnIdle { state.click("seasons", 4) }
assertEquals("Cached episodes are installed synchronously", listOf(fifth), state.data.episodes)
assertEquals(TitleReadStatus.Ready, state.data.episodesStatus)
tick()
// Frame 0 may retain the outgoing real cards; the first incoming subtree is due by frame 1.
repeat(4) { index ->
assertEquals(listOf(fifth), state.data.episodes)
assertEquals(TitleReadStatus.Ready, state.data.episodesStatus)
val slot = hasTestTag("title-episode-slot-0")
rule.onAllNodes(slot, useUnmergedTree = true).assertCountEquals(1)
rule.onNode(slot and hasAnyDescendant(hasClickAction()), useUnmergedTree = true).assertExists()
rule.onAllNodes(hasText("Cached episode") or hasText("New episode"), useUnmergedTree = true).assertCountEquals(1)
val bounds = rule.onNode(slot, useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
val pixel = frame("E07", "cached-return", index) { bitmap ->
val pixels = (1..3).map { part ->
bitmap.getPixel((bounds.left + bounds.width * part / 4).toInt(), (bounds.top + 24).toInt())
}
assertTrue("Every frame has opaque real episode art, never a skeleton, blank row or partial alpha",
pixels.all { it == android.graphics.Color.RED || it == android.graphics.Color.GREEN })
assertEquals("The episode artwork stays uniformly opaque", 1, pixels.distinct().size)
pixels.first()
}
if (index >= 1) {
assertEquals("The first incoming frame paints cached Season 5", android.graphics.Color.RED, pixel)
rule.onNode(hasText("Cached episode") and hasAnyAncestor(slot), useUnmergedTree = true).assertExists()
}
tick()
}
rule.onNode(hasClickAction() and hasAnyAncestor(hasTestTag("title-episode-slot-0")), useUnmergedTree = true)
.performSemanticsAction(androidx.compose.ui.semantics.SemanticsActions.OnClick) { it() }
assertEquals(Destination.Wolfskin(titleRoute(fifth.id)), navigation.backStack.last())
rule.runOnIdle { navigation.goBack() }
rule.runOnIdle { refresh.completeExceptionally(IllegalStateException("offline")) }
rule.waitUntil(5000) { shadowOf(Looper.getMainLooper()).idle(); state.toast == RecoveryMessage.StillUnreachable }
assertEquals(listOf(fifth), state.data.episodes)
saveSamples("E07")
}
@Test fun titleScrollKeepsTheCastPinnedWithoutChangingFocus() = titleScroll(false)
@Test fun reducedTitleScrollSnapsWithoutChangingFocus() = titleScroll(true)
private fun titleScroll(reducedMotion: Boolean) {
val data = fixture().title("Andor", "justin")
render(data.copy(episodes = data.episodes.distinctBy { it.id }), reducedMotion)
rule.runOnIdle { state.focus("cast", 0) }
val positions = mutableListOf<Int>()
repeat(26) { index ->
frame("E05", "title-scroll", index) { brightness(it, 400, 900) }
val offset = shell.view.listState.layoutInfo.visibleItemsInfo.firstOrNull { it.key == "cast" }?.offset
if (offset != null) positions.add(offset)
tick()
}
rule.mainClock.advanceTimeUntil(2000) { !shell.view.listState.isScrollInProgress }
rule.waitForIdle()
val settled = shell.view.listState.layoutInfo.visibleItemsInfo.first { it.key == "cast" }.offset
assertEquals((WolfskinTokens.Layout.pinnedLine.value * 2).toInt(), settled)
if (reducedMotion) assertEquals(positions.last(), positions.first())
else positions.zipWithNext().forEach { (before, after) -> assertTrue(after <= before + 1) }
assertEquals("cast", state.row); assertEquals(0, state.column); assertFalse(shell.navFocused)
saveSamples("E05")
}
@Test fun personScrollKeepsTheSelectedCreditRowPinned() = personScroll(false)
@Test fun reducedPersonScrollSnapsToTheSelectedCreditRow() = personScroll(true)
private fun personScroll(reducedMotion: Boolean) {
reduced.value = reducedMotion
prepareShell()
val person = PersonState(TitlePerson("actor", "Actor"), List(30) { TitleItem("credit-$it", "Credit $it", "movie") }, onAction = { })
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced.value) { WolfskinTheme {
PersonSurface(person, shell, Modifier.requiredSize(960.dp, 540.dp))
} } }
rule.waitForIdle(); rule.mainClock.advanceTimeBy(600); rule.mainClock.autoAdvance = false
rule.runOnIdle { person.index = 24 }
val positions = mutableListOf<Int>()
repeat(26) { index ->
frame("E05", "person-scroll", index) { brightness(it, 400, 900) }
val offset = shell.view.listState.layoutInfo.visibleItemsInfo.firstOrNull { it.key == "credit-24" }?.offset
if (offset != null) positions.add(offset)
tick()
}
rule.mainClock.advanceTimeUntil(2000) { !shell.view.listState.isScrollInProgress }
rule.waitForIdle()
val settled = shell.view.listState.layoutInfo.visibleItemsInfo.first { it.key == "credit-24" }.offset
assertEquals((WolfskinTokens.Layout.pinnedLine.value * 2).toInt(), settled)
if (reducedMotion) assertEquals(positions.last(), positions.first())
else positions.zipWithNext().forEach { (before, after) -> assertTrue(after <= before + 1) }
assertEquals(24, person.index); assertFalse(shell.navFocused)
saveSamples("E05")
}
@Test fun cardFirstPaintUsesTheCurrentFractionBeforeAnyAnimationFrame() {
rule.mainClock.autoAdvance = false
rule.setContent { WolfskinTheme {
Box(Modifier.requiredSize(960.dp, 540.dp).background(WolfskinTokens.Color.bg)) {
TitleCard(false, Modifier.testTag("first-card"), progress = .134f) { }
}
} }
rule.waitForIdle()
val bounds = rule.onNodeWithTag("first-card").fetchSemanticsNode().boundsInRoot
val y = (bounds.bottom - (WolfskinTokens.CardMetadata.bottom.value + WolfskinTokens.CardMetadata.resumeLine.value / 2) * 2).toInt()
val edge = frame("E06", "card-first-paint", 0) { bitmap ->
(0 until bounds.width.toInt()).lastOrNull { bitmap.getPixel(bounds.left.toInt() + it, y) == WolfskinTokens.Color.accent.toArgb() }?.plus(1) ?: 0
}
assertEquals(bounds.width * .134f, edge.toFloat(), 1f)
}
@Test fun seasonCapsuleRetargetsWithoutStoppingBetweenChoices() = retargetSeason(false)
@Test fun reducedSeasonCapsuleRetargetsInOneFrame() = retargetSeason(true)
private fun retargetSeason(reducedMotion: Boolean) {
val series = TitleItem("repeat", "Series", "tv", held = true, seasons = 8)
render(TitlePageData(series, selectedSeason = 3), reducedMotion)
rule.runOnIdle { state.focus("seasons", 0) }
repeat(25) { tick() }
val row = rule.onNode(hasText("Season 5") and hasClickAction()).fetchSemanticsNode().boundsInRoot
fun left(bitmap: android.graphics.Bitmap): Int = (0 until bitmap.width).firstOrNull { x ->
val pixel = bitmap.getPixel(x, (row.top + 4).toInt())
android.graphics.Color.red(pixel) > 180 && android.graphics.Color.green(pixel) < 210 && android.graphics.Color.blue(pixel) > 230
} ?: -1
rule.runOnIdle { state.click("seasons", 3); state.focus("seasons", 0) }
val first = List(6) { index -> frame("E07", "season-repeat-first", index, ::left).also { tick() } }
rule.runOnIdle { state.click("seasons", 4); state.focus("seasons", 0) }
val second = List(23) { index -> frame("E07", "season-repeat-second", index, ::left).also { tick() } }
assertTrue(first.all { it >= 0 } && second.all { it >= 0 })
if (reducedMotion) assertEquals(second.last(), second[1]) else {
val before = first.last() - first[first.lastIndex - 1]
val after = second[2] - second[1]
assertTrue("A moving capsule cannot restart from zero velocity: before=$before, after=$after", before > 0 && after >= before * .75f)
second.zipWithNext().forEach { (previous, next) -> assertTrue(next >= previous - 1) }
}
saveSamples("E07")
}
@Test fun backCancelsPreparationWithoutALateFailureToast() = cancelPreparation(false)
@Test fun reducedBackCancelsPreparationWithoutALateFailureToast() = cancelPreparation(true)
private fun cancelPreparation(reducedMotion: Boolean) {
val (_, prepared, navigation) = pendingPlayback(reducedMotion)
rule.runOnIdle { state.click("header", 0) }
tick()
rule.runOnIdle { state.click("header", state.options.indexOfFirst { it.id == "version" }) }
tick()
rule.runOnIdle { state.back(shell) }
assertNull(state.playbackStart.starting)
assertNull(state.sheet)
rule.runOnIdle { prepared.completeExceptionally(IllegalStateException("late failure")) }
repeat(24) { tick() }
assertNull(state.toast)
assertFalse(navigation.backStack.last() is Destination.Playback)
rule.onNodeWithText("Starting…").assertDoesNotExist()
rule.onNodeWithText(RecoveryMessage.PlaybackUnavailable).assertDoesNotExist()
}
@Test fun leavingTheTitleEntryCancelsPreparationWithoutALatePlayer() = cancelOwnership(false)
@Test fun reducedLeavingTheTitleEntryCancelsPreparationWithoutALatePlayer() = cancelOwnership(true)
private fun cancelOwnership(reducedMotion: Boolean) {
val (facade, initial, navigation) = pendingPlayback(reducedMotion)
var prepared = initial
coEvery { facade.preparePlayback(id) } coAnswers { prepared.await() }
for (inactive in listOf(true, false)) {
rule.runOnIdle { state.click("header", 0) }
tick()
rule.runOnIdle { if (inactive) entryActive.value = false else state.click("related", 0) }
tick()
rule.mainClock.advanceTimeUntil(timeoutMillis = 80) {
!rule.onNodeWithTag("title-page").fetchSemanticsNode().config
.contains(androidx.compose.ui.semantics.SemanticsProperties.Focused)
}
val destination = navigation.backStack.last()
assertNull("The rendered entry has lost ownership (inactive=$inactive)", state.playbackStart.starting)
rule.runOnIdle { prepared.complete(BaseItemDto(id = id, type = BaseItemKind.MOVIE)) }
repeat(24) { tick() }
assertEquals("A late prepared stream cannot take over the new page", destination, navigation.backStack.last())
assertFalse(destination is Destination.Playback)
assertNull(state.toast)
rule.onNodeWithText("Starting…").assertDoesNotExist()
rule.runOnIdle {
if (inactive) { entryActive.value = true; prepared = CompletableDeferred() } else navigation.goBack()
}
tick()
}
}
}

View file

@ -0,0 +1,89 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv
import android.app.Application
import androidx.activity.ComponentActivity
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.graphics.ImageBitmap
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 androidx.compose.ui.test.performClick
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.takahirom.roborazzi.captureRoboImage
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
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.TitleReadFacade
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import java.io.File
/** Even an incomplete/stale requestable page must recover through Wolfskin, not throw. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class TitleRequestSafetyTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test fun missingCatalogueIdentityShowsRecoveryAndCannotProduceAFakeHideReceipt() {
FakeConductarr().use { fake ->
ConductarrSession(MemoryCacheStore()).use { session ->
session.select("server:shared", SHARED_TOKEN, fake.url)
runBlocking { session.authenticate() }
val facade = mockk<TitleReadFacade>()
coEvery { facade.findHeld(any(), any()) } returns null
val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), NavigationManager(), session, mockk(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true))
val person = ShellPerson("shared", "Shared", "S", true)
val route = titleRoute("movie:42")
val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; enterDestination("discover"); enterDestination(route) }
val art = ImageBitmap(1, 1)
try {
rule.setContent { WolfskinTheme {
val data = model.title?.data
val paths = listOfNotNull(data?.item?.poster, data?.item?.backdrop) +
data?.cast.orEmpty().mapNotNull { it.portrait } +
data?.related.orEmpty().flatMap { listOfNotNull(it.poster, it.backdrop) }
CompositionLocalProvider(LocalTitleBitmaps provides paths.associateWith { art }) {
TitleDestination(route, shell, UserPreferences(AppPreferences.getDefaultInstance(), null), viewModel = model, fixtures = false)
}
} }
rule.waitUntil(30_000) {
Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
model.title?.data?.connection == TitleConnection.SignedIn && model.conductarrAction != null
}
// A stale/partial read must not make the action adapter throw even if its
// previous requestable snapshot still exists.
rule.runOnIdle {
val current = model.title!!
current.update(current.data.copy(item = TitleItem("missing-identity", "Unidentified film", "movie"), pick = true))
}
rule.onNodeWithText("Request").performClick()
rule.onNodeWithText(RecoveryMessage.TitleActionUnavailable).assertIsDisplayed()
rule.onNodeWithText("Unidentified film").assertIsDisplayed()
assertFalse(shell.navFocused)
val directory = File(System.getProperty("wolfskin.captureDirectory"), "title-request-safety").apply { mkdirs() }
rule.onRoot().captureRoboImage(File(directory, "missing-identity-request.png").absolutePath)
rule.onNodeWithText("Not interested").performClick()
assertFalse(model.title!!.data.hidden)
assertNull(model.title!!.sheet)
rule.onNodeWithText(RecoveryMessage.TitleActionUnavailable).assertIsDisplayed()
rule.onRoot().captureRoboImage(File(directory, "missing-identity-hide.png").absolutePath)
} finally { model.viewModelScope.cancel() }
}
}
}
}

View file

@ -0,0 +1,291 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import android.graphics.BitmapFactory
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.unit.dp
import com.github.takahirom.roborazzi.captureRoboImage
import org.json.JSONObject
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.design.*
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import java.io.File
/** Native title interactions keep secondary actions, focus and section geometry intact. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class TitleRound2Test {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private val effects = mutableListOf<TitleAction>()
private lateinit var state: TitleState
private lateinit var shell: ShellState
private fun fixture() = TitleFixture(rule.activity.assets.open("wolfskin-titles.json").bufferedReader().use { JSONObject(it.readText()) })
private fun render(title: String = "Locke", person: String = "justin", transform: (TitlePageData) -> TitlePageData = { it }) {
val fixture = fixture()
shell = ShellState(fixture.people, person).apply { pickerVisible = false; pickerLaunch = false; enterDestination("title/test") }
state = TitleState(transform(fixture.title(title, person)), shell.person, effects::add)
rule.setContent { WolfskinTheme {
BackHandler { state.back(shell) }
TitleSurface(state, shell, Modifier.requiredSize(960.dp, 540.dp))
} }
}
private fun press(key: Key) = rule.onRoot().performKeyInput { pressKey(key) }
private fun menu() {
rule.runOnIdle { state.focus("header", state.options.indexOfFirst { it.id == "menu" }) }
press(Key.DirectionCenter)
rule.mainClock.advanceTimeBy(300)
rule.waitForIdle()
rule.onNodeWithTag("title-menu", useUnmergedTree = true).assertIsDisplayed()
}
@Test fun menuBackRestoresItsButtonAndKeepsMediaInfoAndRequestReachable() {
render()
menu()
val opener = state.column
press(Key.Escape)
assertNull(state.sheet)
assertEquals(opener, state.column)
assertFalse(shell.navFocused)
press(Key.DirectionCenter)
assertEquals(TitleSheet.Menu, state.sheet)
press(Key.DirectionCenter)
assertEquals(TitleSheet.MediaInfo, state.sheet)
rule.onNodeWithText("Container", useUnmergedTree = true).assertIsDisplayed()
press(Key.Escape)
assertEquals(TitleSheet.Menu, state.sheet)
press(Key.DirectionDown)
press(Key.DirectionCenter)
assertNull(state.sheet)
assertEquals(TitleAction.Request(state.data.item, true), effects.single())
assertEquals(opener, state.column)
}
@Test fun notHeldMovieKeepsRequestAndHideOutsideTheMenu() {
render("The Amazing Spider-Man")
rule.onNodeWithTag("title-option-request").assertIsDisplayed()
rule.onNodeWithTag("title-option-hide").assertIsDisplayed()
menu()
rule.onNodeWithText("Add for someone").assertIsDisplayed()
press(Key.DirectionCenter)
assertEquals(TitleAction.Request(state.data.item, true), effects.single())
}
@Test fun movieCastStacksAfterActionsInsteadOfAReservedHeaderHeight() {
render("The Amazing Spider-Man")
val actions = rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot
val cast = rule.onNodeWithTag("title-cast").fetchSemanticsNode().boundsInRoot
val gap = cast.top - actions.bottom
assertTrue("Cast follows the actions without the old fixed header reservation", gap > 0 && cast.top < WolfskinTokens.Layout.row_dhead.value * 2)
rule.runOnIdle { state.update(state.data.copy(requestedStage = "Searching", requestActor = "Justin")) }
val requestedActions = rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot
val requestedCast = rule.onNodeWithTag("title-cast").fetchSemanticsNode().boundsInRoot
assertTrue(requestedActions.bottom < requestedCast.top)
assertTrue("A readiness line cannot move Cast backwards or recreate the old reservation", requestedCast.top >= cast.top && requestedCast.top < WolfskinTokens.Layout.row_dhead.value * 2)
}
@Test fun mussoliniResumeHasOnlyFactsRatingsAndNoRedundantSeasonsAction() {
render("episode-episode-1-2025-s1e1", "shared") { data -> data.copy(episodes = data.episodes.distinctBy { it.id }) }
assertTrue(state.data.playTarget!!.resumeSeconds > 0)
rule.onNodeWithTag("title-progress").assertIsDisplayed()
rule.onAllNodesWithContentDescription("rtCritics", useUnmergedTree = true).assertCountEquals(1)
rule.onNodeWithText("Ep", useUnmergedTree = true).assertIsDisplayed()
rule.onAllNodesWithText("7.4", useUnmergedTree = true).assertCountEquals(1)
rule.onNode(hasText("7.4") and hasAnyAncestor(hasTestTag("title-facts")), useUnmergedTree = true).assertIsDisplayed()
rule.onNodeWithTag("title-option-seasons").assertDoesNotExist()
press(Key.DirectionDown)
assertEquals("seasons", state.row)
rule.onNode(hasText("Season 1") and hasClickAction()).assertIsDisplayed()
}
@Test fun seriesUsesDownToReachSeasonsWithoutARedundantShortcut() {
render("Andor")
rule.onNodeWithTag("title-option-seasons").assertDoesNotExist()
menu()
rule.onNodeWithText("Add for someone").assertIsDisplayed()
press(Key.Escape)
press(Key.DirectionDown)
assertEquals("seasons", state.row)
rule.onNode(hasText("Season 1") and hasClickAction()).assertIsDisplayed()
}
@Test fun episodeFactsUseTheEpisodeSetRatherThanTheSeriesSet() {
render("episode-episode-1-2025-s1e1", "shared") { data ->
data.copy(item = data.item.copy(ratings = listOf(LibraryRating("rtCritics", 20.0, "Rotten"))),
watchTarget = data.watchTarget.copy(ratings = listOf(LibraryRating("rtCritics", 84.0, "Fresh"), LibraryRating("tmdb", 8.7, episode = true))),
episodes = data.episodes.distinctBy { it.id })
}
rule.onAllNodesWithText("84%", useUnmergedTree = true).assertCountEquals(1)
rule.onAllNodesWithText("8.7", useUnmergedTree = true).assertCountEquals(1)
rule.onNodeWithText("20%", useUnmergedTree = true).assertDoesNotExist()
rule.onNode(hasText("8.7") and hasAnyAncestor(hasTestTag("title-facts")), useUnmergedTree = true).assertIsDisplayed()
}
@Test fun episodeSkeletonAndReadySlotsMatchWhileMetadataFitsOneLine() {
render("Andor") { it.copy(episodesStatus = TitleReadStatus.Loading) }
rule.runOnIdle { shell.view.listState.requestScrollToItem(2) }
rule.waitForIdle()
val skeleton = rule.onNodeWithTag("title-episode-slot-0").fetchSemanticsNode().boundsInRoot
rule.runOnIdle { state.update(state.data.copy(episodesStatus = TitleReadStatus.Ready)) }
val ready = rule.onNodeWithTag("title-episode-slot-0").fetchSemanticsNode().boundsInRoot
assertEquals(skeleton, ready)
assertEquals(448f, ready.width, 0.1f)
assertEquals("Wider cards retain the approved taller episode art", 280f, ready.height, 0.1f)
rule.runOnIdle { state.focus("episodes", 0) }
val episode = state.data.episodes.first()
rule.onNodeWithText(episode.episodeLabel + " · " + episode.runtime).assertIsDisplayed()
rule.onNode(hasText(episode.seriesName!!) and hasAnyAncestor(hasTestTag("title-episode-slot-0")), useUnmergedTree = true).assertDoesNotExist()
}
@Test fun revokedRequestsDisappearFromMenuWithoutRemovingMediaInfo() {
render()
menu()
rule.runOnIdle { state.update(state.data.copy(requestsAllowed = false)) }
rule.onNodeWithText("Add for someone").assertDoesNotExist()
press(Key.DirectionCenter)
assertEquals(TitleSheet.MediaInfo, state.sheet)
rule.onNodeWithText("Container", useUnmergedTree = true).assertIsDisplayed()
}
@Test fun shortOverviewMoviesKeepCastBelowThePosterForHeldAndNotAddedTitles() {
render("The Amazing Spider-Man") { it.copy(item = it.item.copy(overview = "Short overview.", backdrop = null, isPosterBackdrop = true)) }
for (held in listOf(false, true)) {
rule.runOnIdle { state.update(state.data.copy(item = state.data.item.copy(held = held))) }
val poster = rule.onNodeWithTag("title-poster-backdrop").fetchSemanticsNode().boundsInRoot
val firstActor = rule.onNodeWithTag("title-cast-${state.data.cast.first().id}").fetchSemanticsNode().boundsInRoot
assertTrue("Cast clears the poster on held=$held: $firstActor vs $poster", firstActor.top >= poster.bottom + 20f - 0.5f)
}
}
@Test fun revokingTheOnlyMenuActionClosesAnOtherwiseEmptyMenu() {
render("The Amazing Spider-Man")
menu()
rule.runOnIdle { state.update(state.data.copy(requestsAllowed = false)) }
assertNull(state.sheet)
rule.mainClock.advanceTimeBy(300)
rule.onNodeWithTag("title-menu", useUnmergedTree = true).assertDoesNotExist()
}
@Test fun mediaInfoKeepsTableTextSeparatedInsteadOfAccumulatingOverlap() {
render()
menu()
press(Key.DirectionCenter)
rule.onNodeWithText("Container", useUnmergedTree = true).assertIsDisplayed()
rule.onNodeWithText("Dutch", useUnmergedTree = true).assertIsDisplayed()
val text = rule.onAllNodes(SemanticsMatcher.keyIsDefined(androidx.compose.ui.semantics.SemanticsActions.GetTextLayoutResult) and hasAnyAncestor(hasTestTag("title-sheet")), useUnmergedTree = true)
.fetchSemanticsNodes().map { it.boundsInRoot }.filter { it.width > 0 && it.height > 0 }
text.forEachIndexed { index, first -> text.drop(index + 1).forEach { second ->
if (minOf(first.right, second.right) > maxOf(first.left, second.left)) {
assertTrue("Media info text must never collide vertically: $first vs $second", minOf(first.bottom, second.bottom) <= maxOf(first.top, second.top))
}
} }
}
@Test fun menuToMediaInfoNeverDoubleDimsOrPopsAndBackKeepsOneScrim() {
render { it.copy(item = it.item.copy(backdrop = null, poster = null), cast = emptyList(), related = emptyList()) }
menu()
rule.mainClock.autoAdvance = false
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "ui-polish/P06/menu-to-info").apply { mkdirs() }
rule.runOnIdle { state.clickSheet(0) }
var waited = 0
while (rule.onAllNodesWithTag("title-sheet", useUnmergedTree = true).fetchSemanticsNodes().isEmpty() && waited <= WolfskinTokens.Motion.sheetIn + 16) {
rule.mainClock.advanceTimeByFrame()
rule.waitForIdle()
waited += 16
}
val records = mutableListOf<String>()
fun frame(phase: String, index: Int): Pair<Int, Int> {
val path = File(directory, "$phase-${index.toString().padStart(2, '0')}.png")
rule.onRoot().captureRoboImage(path.absolutePath)
val bitmap = BitmapFactory.decodeFile(path.absolutePath)
fun brightness(x: Int, y: Int): Int {
val pixel = bitmap.getPixel(x, y)
return (android.graphics.Color.red(pixel) + android.graphics.Color.green(pixel) + android.graphics.Color.blue(pixel)) / 3
}
return try {
(brightness(1900, 900) to brightness(100, 900)).also { records.add("$phase,${index * 16},${it.first},${it.second}") }
} finally { bitmap.recycle() }
}
val info = List(14) { index ->
rule.waitForIdle()
frame("info", index).also { rule.mainClock.advanceTimeBy(16); rule.waitForIdle() }
}
info.forEach { assertEquals("Media info keeps one full scrim on every frame", info.last().second, it.second) }
info.zipWithNext().forEach { (before, after) -> assertTrue("The panel fades monotonically instead of popping", after.first >= before.first - 1) }
assertNotEquals("The Media info panel visibly fades", info.first().first, info.last().first)
assertEquals("The panel settles by sheetIn plus one frame", info.last().first, info[(WolfskinTokens.Motion.sheetIn / 16 + 1).coerceAtMost(info.lastIndex)].first)
rule.runOnIdle { state.back(shell) }
repeat(14) { index ->
rule.mainClock.advanceTimeBy(16)
rule.waitForIdle()
assertEquals("Returning to Menu keeps exactly one scrim", info.last().second, frame("back", index).second)
}
rule.onNodeWithTag("title-menu", useUnmergedTree = true).assertExists()
File(directory, "samples.csv").writeText("phase,timeMs,sheetBrightness,leftBrightness\n" + records.joinToString("\n"))
rule.mainClock.autoAdvance = true
}
@Test fun menuFadeFramesStayMonotonicAndNeverTranslate() {
render { it.copy(item = it.item.copy(backdrop = null, poster = null), cast = emptyList(), related = emptyList()) }
rule.mainClock.autoAdvance = false
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "title-menu-motion").apply { mkdirs() }
rule.runOnIdle { state.click("header", state.options.indexOfFirst { it.id == "menu" }) }
var waited = 0
while (rule.onAllNodesWithTag("title-menu", useUnmergedTree = true).fetchSemanticsNodes().isEmpty() && waited <= WolfskinTokens.Motion.base + 100) {
rule.mainClock.advanceTimeByFrame()
rule.waitForIdle()
waited += 16
}
val bounds = rule.onNodeWithTag("title-menu", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
val samples = mutableListOf<String>()
fun frame(phase: String, index: Int): Int {
val file = File(directory, "$phase-${index.toString().padStart(2, '0')}.png")
rule.onRoot().captureRoboImage(file.absolutePath)
val image = BitmapFactory.decodeFile(file.absolutePath)
val brightness = try {
val pixel = image.getPixel((bounds.left + 40).toInt(), (bounds.top + 6).toInt())
android.graphics.Color.red(pixel) + android.graphics.Color.green(pixel) + android.graphics.Color.blue(pixel)
} finally { image.recycle() }
samples.add("$phase,${index * 16},$brightness,${bounds.left},${bounds.top}")
return brightness
}
fun assertMonotonic(values: List<Int>) {
assertNotEquals("Fade must visibly change the menu pixel", values.first(), values.last())
val direction = if (values.last() > values.first()) 1 else -1
values.zipWithNext().forEach { (before, after) ->
assertTrue("Menu alpha must not flicker or snap back: $values", (after - before) * direction >= -1)
}
}
val entering = List(14) { index ->
rule.waitForIdle()
assertEquals(bounds, rule.onNodeWithTag("title-menu", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot)
frame("open", index).also { rule.mainClock.advanceTimeBy(16); rule.waitForIdle() }
}
assertMonotonic(entering)
rule.runOnIdle { state.back(shell) }
val leaving = mutableListOf<Int>()
repeat(9) { index ->
rule.waitForIdle()
if (rule.onAllNodesWithTag("title-menu", useUnmergedTree = true).fetchSemanticsNodes().isNotEmpty()) leaving.add(frame("close", index))
rule.mainClock.advanceTimeBy(16)
}
assertMonotonic(leaving)
rule.onNodeWithTag("title-menu").assertDoesNotExist()
File(directory, "samples.csv").writeText("phase,timeMs,brightness,leftPx,topPx\n" + samples.joinToString("\n"))
rule.mainClock.autoAdvance = true
press(Key.DirectionCenter)
assertEquals(TitleSheet.Menu, state.sheet)
}
}

View file

@ -0,0 +1,169 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import android.content.Context
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import androidx.compose.ui.unit.dp
import com.github.damontecres.wholphin.WholphinApplication
import com.github.damontecres.wholphin.data.ItemPlaybackRepository
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.ImageUrlService
import com.github.damontecres.wholphin.ui.successQueryResult
import com.github.damontecres.wholphin.ui.successResponse
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.unmockkObject
import kotlinx.coroutines.runBlocking
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.BaseItemKind
import org.jellyfin.sdk.model.api.request.GetEpisodesRequest
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.design.ShellPerson
import org.justinvisser.wolfskin.design.ShellState
import org.justinvisser.wolfskin.design.TitleAction
import org.justinvisser.wolfskin.design.TitlePageData
import org.justinvisser.wolfskin.design.TitleState
import org.justinvisser.wolfskin.design.TitleSurface
import org.justinvisser.wolfskin.design.WolfskinTheme
import org.justinvisser.wolfskin.tv.read.TitleReadFacade
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import java.util.UUID
/** The actual SDK projection and native reel must agree on chronology and the activated episode. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34, 36], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class TitleSeasonReelTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@get:Rule val directory = TemporaryFolder()
private val seriesId = UUID(0, 1)
private val series = BaseItemDto(id = seriesId, name = "Desperate Housewives", type = BaseItemKind.SERIES,
providerIds = mapOf("Tmdb" to "693"), people = emptyList())
private val episodes = (1..24).map { number ->
BaseItemDto(id = UUID(0, 100L + number), name = "Episode $number", type = BaseItemKind.EPISODE,
seriesId = seriesId, parentIndexNumber = 5, indexNumber = number,
providerIds = mapOf("Tmdb" to number.toString()), people = emptyList(),
userData = playbackUserData(UUID(0, 100L + number), 0, played = number < 24))
}
private val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null)
private lateinit var facade: TitleReadFacade
private lateinit var state: TitleState
private val actions = mutableListOf<TitleAction>()
@Before fun setup() {
mockkObject(WholphinApplication.Companion)
every { WholphinApplication.instance } returns mockk {
every { resources } returns rule.activity.resources
}
val library = mockk<UserLibraryApi>()
val items = mockk<ItemsApi>()
val shows = mockk<TvShowsApi>()
val api = mockk<ApiClient>()
every { api.userLibraryApi } returns library
every { api.itemsApi } returns items
every { api.tvShowsApi } returns shows
coEvery { library.getItem(seriesId) } returns successResponse(series)
episodes.forEach { episode -> coEvery { library.getItem(episode.id) } returns successResponse(episode) }
coEvery { items.getItems(any<GetItemsRequest>()) } returns successQueryResult((1..8).map { number ->
BaseItemDto(id = UUID(0, 10L + number), type = BaseItemKind.SEASON, indexNumber = number)
})
coEvery { shows.getEpisodes(any<GetEpisodesRequest>()) } returns successQueryResult(episodes)
coEvery { shows.getNextUp(any<GetNextUpRequest>()) } returns successQueryResult(listOf(episodes.last()))
val playback = mockk<ItemPlaybackRepository>()
coEvery { playback.getSelectedTracks(any(), any(), any()) } returns null
val repository = mockk<ServerRepository> {
every { currentServer } returns null
every { currentUser } returns null
}
val context = mockk<Context> { every { filesDir } returns directory.root }
facade = TitleReadFacade(api, mockk<ImageUrlService>(relaxed = true), playback, repository, mockk(), context)
}
@After fun teardown() { unmockkObject(WholphinApplication.Companion) }
private fun render(page: TitlePageData) {
val viewer = ShellPerson("justin", "Justin", "J", false)
val shell = ShellState(listOf(viewer), viewer.key).apply {
pickerVisible = false; pickerLaunch = false; enterDestination("title/series")
}
state = TitleState(page, viewer, actions::add)
rule.setContent { WolfskinTheme { TitleSurface(state, shell, Modifier.requiredSize(960.dp, 540.dp)) } }
press(Key.DirectionDown)
press(Key.DirectionDown)
repeat(4) { press(Key.DirectionLeft) }
assertEquals("episodes", state.row)
assertEquals(0, state.column)
rule.onNodeWithTag("title-page").assertIsFocused()
}
@Test fun nextUpAtSeasonEndCannotMoveTheFinalEpisodeToTheStartOfTheReel() = runBlocking<Unit> {
val page = facade.read(seriesId, 5, preferences)
render(page)
press(Key.DirectionCenter)
assertEquals("The first tile must open E1, not next-up E24", episodes.first().id.toString(), openedEpisode())
assertEquals("Play remains next-up even though the reel is chronological", episodes.last().id.toString(), page.playTarget?.id)
assertEquals((1..24).toList(), page.episodes.map { it.episode })
val saved = requireNotNull(facade.readSaved(seriesId, preferences))
assertEquals(page.episodes.map { it.id }, saved.episodes.map { it.id })
repeat(7) { press(Key.DirectionRight) }
repeat(2) { press(Key.DirectionLeft) }
press(Key.DirectionUp)
press(Key.DirectionDown)
press(Key.DirectionCenter)
assertEquals("Row return and Left/Right preserve E6", episodes[5].id.toString(), openedEpisode())
rule.onNodeWithTag("title-episode-slot-5").assertIsDisplayed()
}
@Test fun openingAnotherEpisodeAndRefreshingCannotReorderTheRememberedReel() = runBlocking<Unit> {
val opened = facade.read(episodes[11].id, 5, preferences)
render(opened)
repeat(12) { press(Key.DirectionRight) }
assertEquals(episodes[12].id.toString(), state.data.episodes[state.column].id)
val refreshed = facade.read(seriesId, 5, preferences)
rule.runOnIdle { state.update(refreshed) }
press(Key.DirectionCenter)
assertEquals("Refresh must not change E13 into another episode", episodes[12].id.toString(), openedEpisode())
assertEquals(episodes[11].id.toString(), opened.playTarget?.id)
assertEquals(episodes[11].id.toString(), opened.watchTarget.id)
assertEquals(opened.episodes.map { it.id }, refreshed.episodes.map { it.id })
rule.onNodeWithTag("title-episode-slot-12").assertIsDisplayed()
}
private fun openedEpisode() = (actions.last() as TitleAction.OpenTitle).item.id
private fun press(key: Key) {
rule.onRoot().performKeyInput { pressKey(key) }
rule.mainClock.advanceTimeBy(400)
rule.waitForIdle()
}
}

View file

@ -0,0 +1,228 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import androidx.activity.ComponentActivity
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.InterfacePreferences
import com.github.damontecres.wholphin.preferences.ThemeSongVolume
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.ThemeSongPlayer
import com.github.damontecres.wholphin.ui.nav.Destination
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.cancel
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.junit.After
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.ScreenEntry
import org.justinvisser.wolfskin.design.ShellPerson
import org.justinvisser.wolfskin.design.ShellState
import org.justinvisser.wolfskin.design.TitleItem
import org.justinvisser.wolfskin.design.TitlePageData
import org.justinvisser.wolfskin.design.WolfskinTheme
import org.justinvisser.wolfskin.design.WolfskinTokens
import org.justinvisser.wolfskin.tv.read.TitleReadFacade
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import java.util.UUID
/** Real Compose/lifecycle transitions must relinquish theme ownership before another page or video. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class TitleThemeMusicTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private val movieId = UUID.fromString("00000000-0000-0000-0000-000000000041")
private val seriesId = UUID.fromString("00000000-0000-0000-0000-000000000042")
private val episodeId = UUID.fromString("00000000-0000-0000-0000-000000000043")
private val movie = TitleItem(movieId.toString(), "Held movie", "movie", held = true)
private val playback = ThemePlayback()
private val item = mutableStateOf<TitleItem?>(movie)
private val preferences = mutableStateOf(preferences(ThemeSongVolume.HIGH))
private val active = mutableStateOf(true)
private val visible = mutableStateOf(true)
private val viewer = mutableStateOf("server:justin")
private val lifecycleOwner = ThemeLifecycleOwner()
private var model: WolfskinTitleViewModel? = null
private var session: ConductarrSession? = null
@After fun dispose() {
model?.viewModelScope?.cancel()
session?.close()
}
@Test fun heldMovieStartsOnceAcrossRefreshAndRecreationNeverOverlaps() {
mountEffect()
assertEquals(listOf("play:$movieId"), playback.events)
rule.runOnIdle { item.value = movie.copy(name = "Refreshed metadata") }
rule.waitForIdle()
assertEquals(listOf("play:$movieId"), playback.events)
rule.runOnIdle { visible.value = false }
rule.waitForIdle()
assertEquals(listOf("play:$movieId", "stop"), playback.events)
rule.runOnIdle { visible.value = true }
rule.waitForIdle()
assertEquals(listOf("play:$movieId", "stop", "play:$movieId"), playback.events)
}
@Test fun episodeUsesItsSeriesAndMissingSeriesStaysSilent() {
item.value = movie.copy(id = episodeId.toString(), kind = "episode", seriesId = seriesId.toString())
mountEffect()
assertEquals(listOf("play:$seriesId"), playback.events)
rule.runOnIdle { item.value = item.value!!.copy(seriesId = null) }
rule.waitForIdle()
assertEquals(listOf("play:$seriesId", "stop"), playback.events)
}
@Test fun catalogueOnlyAndDisabledTitlesStaySilentAndDisablingStopsPlayingTheme() {
item.value = movie.copy(id = "movie:42", held = false)
mountEffect()
assertEquals(emptyList<String>(), playback.events)
rule.runOnIdle {
item.value = movie
preferences.value = preferences(ThemeSongVolume.DISABLED)
}
rule.waitForIdle()
assertEquals(emptyList<String>(), playback.events)
rule.runOnIdle { preferences.value = preferences(ThemeSongVolume.HIGH) }
rule.waitForIdle()
assertEquals(listOf("play:$movieId"), playback.events)
rule.runOnIdle { preferences.value = preferences(ThemeSongVolume.DISABLED) }
rule.waitForIdle()
assertEquals(listOf("play:$movieId", "stop"), playback.events)
}
@Test fun pausedPageStopsAndOnlyResumedPageRestarts() {
rule.runOnIdle { lifecycleOwner.registry.currentState = Lifecycle.State.STARTED }
mountEffect(resumed = false)
assertEquals(emptyList<String>(), playback.events)
rule.runOnIdle { lifecycleOwner.registry.currentState = Lifecycle.State.RESUMED }
rule.waitForIdle()
assertEquals(listOf("play:$movieId"), playback.events)
rule.runOnIdle { lifecycleOwner.registry.currentState = Lifecycle.State.CREATED }
rule.waitForIdle()
assertEquals(listOf("play:$movieId", "stop"), playback.events)
rule.runOnIdle { lifecycleOwner.registry.currentState = Lifecycle.State.RESUMED }
rule.waitForIdle()
assertEquals(listOf("play:$movieId", "stop", "play:$movieId"), playback.events)
}
@Test fun outgoingEntryStopsEvenWhileStillComposedAndCanResumeOnBack() {
mountEffect()
rule.runOnIdle { active.value = false }
rule.waitForIdle()
assertEquals(listOf("play:$movieId", "stop"), playback.events)
rule.runOnIdle { item.value = movie.copy(overview = "Exit animation still composed") }
rule.waitForIdle()
assertNull(playback.playing)
rule.runOnIdle { active.value = true }
rule.waitForIdle()
assertEquals(listOf("play:$movieId", "stop", "play:$movieId"), playback.events)
}
@Test fun personSwitchReleasesOldThemeAndSignOutDisposesIt() {
mountEffect()
rule.runOnIdle { viewer.value = "server:shared" }
rule.waitForIdle()
assertEquals(listOf("play:$movieId", "stop", "play:$movieId"), playback.events)
rule.runOnIdle { visible.value = false }
rule.waitForIdle()
assertNull(playback.playing)
assertEquals("stop", playback.events.last())
}
@Test fun realEpisodePageStartsSeriesThemeAndStopsBeforePlaybackNavigation() {
val series = movie.copy(id = seriesId.toString(), name = "Series", kind = "tv")
val episode = movie.copy(id = episodeId.toString(), name = "Episode", kind = "episode", seriesId = seriesId.toString())
val navigation = NavigationManager()
val route = titleRoute(episodeId.toString())
val destination = Destination.Wolfskin(route)
val person = ShellPerson("justin", "Justin", "J", false)
val shell = ShellState(listOf(person), person.key).apply {
pickerVisible = false; pickerLaunch = false; enterDestination(route)
}
val facade = mockk<TitleReadFacade>()
coEvery { facade.readSaved(episodeId, any()) } returns null
coEvery { facade.read(episodeId, any(), any()) } returns TitlePageData(series, playTarget = episode, watchTarget = episode)
coEvery { facade.preparePlayback(episodeId) } returns BaseItemDto(id = episodeId, type = BaseItemKind.EPISODE)
val conductarr = ConductarrSession(MemoryCacheStore()).also { session = it }
val titleModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), navigation, conductarr,
mockk(relaxed = true), playback.player, trailerReturn = mockk(relaxed = true)).also { model = it }
rule.runOnIdle { navigation.navigateTo(destination) }
rule.setContent {
WolfskinTheme {
ScreenEntry(shell, route, navigation.backStack.lastOrNull() == destination) {
TitleDestination(route, shell, preferences.value, viewModel = titleModel, fixtures = false)
}
}
}
rule.waitUntil(5_000) { playback.playing == seriesId }
assertEquals(listOf("play:$seriesId"), playback.events)
playback.onStop = { if (playback.playing != null) assertEquals(destination, navigation.backStack.last()) }
rule.runOnIdle { titleModel.title!!.click("header", 0) }
rule.mainClock.advanceTimeBy(WolfskinTokens.Motion.base.toLong() + 48)
rule.waitForIdle()
rule.waitUntil(5_000) { navigation.backStack.last() is Destination.Playback }
playback.onStop = {}
rule.waitForIdle()
assertEquals(Destination.Playback(episodeId, 0L), navigation.backStack.last())
assertNull(playback.playing)
assertEquals(1, playback.events.count { it.startsWith("play:") })
}
private fun mountEffect(resumed: Boolean = true) {
if (resumed) rule.runOnIdle { lifecycleOwner.registry.currentState = Lifecycle.State.RESUMED }
rule.setContent {
CompositionLocalProvider(LocalLifecycleOwner provides lifecycleOwner) {
if (visible.value) TitleThemeMusic(item.value, preferences.value, playback.player, active.value, viewer.value)
}
}
rule.waitForIdle()
}
private fun preferences(volume: ThemeSongVolume) = UserPreferences(
AppPreferences.newBuilder().setInterfacePreferences(
InterfacePreferences.newBuilder().setPlayThemeSongs(volume).build(),
).build(), null,
)
private class ThemeLifecycleOwner : LifecycleOwner {
val registry = LifecycleRegistry(this)
override val lifecycle: Lifecycle get() = registry
}
private class ThemePlayback {
val player = mockk<ThemeSongPlayer>()
val events = mutableListOf<String>()
var playing: UUID? = null
var onStop: () -> Unit = {}
init {
every { player.playThemeFor(any()) } answers {
assertNull("Two title entries must never own theme playback simultaneously", playing)
playing = firstArg()
events += "play:$playing"
}
every { player.stop() } answers {
onStop()
playing = null
events += "stop"
}
}
}
}

View file

@ -19,26 +19,28 @@ import org.robolectric.annotation.Config
@Config(sdk = [34], application = Application::class) @Config(sdk = [34], application = Application::class)
class TitleTrailerTest { class TitleTrailerTest {
private val context = ApplicationProvider.getApplicationContext<Context>() private val context = ApplicationProvider.getApplicationContext<Context>()
private val url = "https://www.youtube.com/watch?v=abcdefghijk" private val url = "https://www.youtube.com/watch?v=ab_-defghij"
@Test fun missingYoutubeHandlerStaysOnTheTitleEvenWhenABrowserExists() { @Test fun missingYoutubeHandlerStaysOnTheTitleEvenWhenABrowserExists() {
register(Intent(Intent.ACTION_VIEW, Uri.parse(url)), "test.browser") register(Intent(Intent.ACTION_VIEW, Uri.parse("https://www.youtube.com/watch?v=ab_-defghij")), "test.browser")
assertFalse(TitleTrailer(context).open(url)) assertFalse(TitleTrailer(context).open(url) {})
assertNull(shadowOf(context as Application).nextStartedActivity) assertNull(shadowOf(context as Application).nextStartedActivity)
} }
@Test fun installedYoutubeHandlerReceivesAnExplicitPackageAndNoBrowserUrl() { @Test fun installedYoutubeHandlerReceivesAnExplicitPackageAndNoBrowserUrl() {
register(Intent(Intent.ACTION_VIEW, Uri.parse("vnd.youtube:abcdefghijk")), "test.youtube") register(Intent(Intent.ACTION_VIEW, Uri.parse("vnd.youtube://ab_-defghij")), "test.youtube")
assertTrue(TitleTrailer(context).open(url)) assertTrue(TitleTrailer(context).open(url) {})
val launched = shadowOf(context as Application).nextStartedActivity val launched = shadowOf(context as Application).nextStartedActivity
assertEquals(Intent.ACTION_VIEW, launched.action)
assertEquals("test.youtube", launched.`package`) assertEquals("test.youtube", launched.`package`)
assertEquals("vnd.youtube", launched.data?.scheme) assertEquals("vnd.youtube", launched.data?.scheme)
assertEquals("abcdefghijk", launched.data?.schemeSpecificPart) // SmartTube (IntentExtractor.extractVideoId) takes the id from Uri.getHost() for this scheme; the opaque `vnd.youtube:ID` form yields null there.
assertEquals("ab_-defghij", launched.data?.host)
} }
@Test fun nonYoutubeTrailerNeverOpensAGeneralViewHandler() { @Test fun nonYoutubeTrailerNeverOpensAGeneralViewHandler() {
register(Intent(Intent.ACTION_VIEW, Uri.parse("https://trailers.example/video")), "test.browser") register(Intent(Intent.ACTION_VIEW, Uri.parse("https://trailers.example/video")), "test.browser")
assertFalse(TitleTrailer(context).open("https://trailers.example/video")) assertFalse(TitleTrailer(context).open("https://trailers.example/video") {})
assertNull(shadowOf(context as Application).nextStartedActivity) assertNull(shadowOf(context as Application).nextStartedActivity)
} }

View file

@ -0,0 +1,150 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import android.os.Bundle
import com.github.damontecres.wholphin.data.CurrentUser
import com.github.damontecres.wholphin.data.SavedSessionIdentity
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.ui.nav.Destination
import io.mockk.every
import io.mockk.mockk
import java.util.UUID
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/** A saved task may carry only a short-lived, exact, unprotected trailer identity; not a generic login. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class)
class TrailerReturnStateTest {
private var elapsed = 1_000L
private var boot = 7
private val clock = mockk<TrailerReturnClock>().also {
every { it.readElapsedMillis() } answers { elapsed }
every { it.readBootCount() } answers { boot }
}
private val server = JellyfinServer(UUID.randomUUID(), "Household", "https://jellyfin.test", null)
private val user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = "never-serialize-this-token")
private val current = CurrentUser(server, user)
private val title = Destination.Wolfskin(titleRoute("Locke"))
private val identity = SavedSessionIdentity(server.id, user.id)
private fun snapshot(person: CurrentUser = current): Bundle {
val originalProcess = TrailerReturnState(clock)
originalProcess.prepare(person, title)
return Bundle().also { originalProcess.save(it, person, title) }
}
@Test fun aNewProcessCanRecoverTheExactTaskIdentityWithoutCopyingCredentials() {
val saved = snapshot()
assertFalse(saved.getString(TrailerReturnState.SAVED_STATE_KEY)!!.contains(user.accessToken!!))
elapsed += 3_000L
assertEquals(identity, TrailerReturnState(clock).restore(saved, title))
}
@Test fun freshLaunchAndCancelledHandoffHaveNoRestorationCapability() {
val state = TrailerReturnState(clock)
state.prepare(current, title)
assertNull(state.restore(null, title))
state.prepare(current, title)
state.clear()
val saved = Bundle().also { state.save(it, current, title) }
assertNull(TrailerReturnState(clock).restore(saved, title))
}
@Test fun theReturnWindowExpiresAtTenMinutesAndRejectsFutureTimestamps() {
for ((age, permitted) in listOf(599_999L to true, 600_000L to false, 14_400_000L to false, -1L to false)) {
elapsed = 1_000L
val saved = snapshot()
elapsed += age
assertEquals("age=$age", if (permitted) identity else null, TrailerReturnState(clock).restore(saved, title))
}
}
@Test fun savingAnOlderHandoffDoesNotRenewItsAuthorizationTime() {
val state = TrailerReturnState(clock)
state.prepare(current, title)
elapsed += 599_999L
val saved = Bundle().also { state.save(it, current, title) }
elapsed += 1L
assertNull(TrailerReturnState(clock).restore(saved, title))
}
@Test fun differentAndUnknownBootsCannotRestoreATask() {
for ((initial, returning) in listOf(7 to 8, 7 to -1, -1 to 7)) {
boot = initial
val saved = snapshot()
boot = returning
assertNull(TrailerReturnState(clock).restore(saved, title))
}
}
@Test fun protectedAndTokenlessPeopleCannotCreateAHandoffRecord() {
for (person in listOf(user.copy(pin = "URDL"), user.copy(requireLogin = true), user.copy(accessToken = null))) {
val saved = snapshot(current.copy(user = person))
assertNull(TrailerReturnState(clock).restore(saved, title))
}
}
@Test fun aPersonProtectionOrDestinationChangeInvalidatesTheTaskSnapshot() {
val otherServer = server.copy(id = UUID.randomUUID())
val changes = listOf(
current.copy(user = user.copy(id = UUID.randomUUID())) to title,
CurrentUser(otherServer, user.copy(serverId = otherServer.id)) to title,
current.copy(user = user.copy(pin = "URDL")) to title,
current to Destination.Wolfskin(titleRoute("another-title")),
)
for ((person, destination) in changes) {
val state = TrailerReturnState(clock)
state.prepare(current, title)
val saved = Bundle().also { state.save(it, person, destination) }
assertNull(TrailerReturnState(clock).restore(saved, destination))
}
}
@Test fun anotherRecoveredTitleAndMalformedStateDoNotAuthorizeRestoration() {
assertNull(TrailerReturnState(clock).restore(snapshot(), Destination.Wolfskin(titleRoute("another-title"))))
val malformed = Bundle().apply { putString(TrailerReturnState.SAVED_STATE_KEY, "not JSON") }
assertNull(TrailerReturnState(clock).restore(malformed, title))
}
@Test fun skippingTheColdChooserRequiresConfirmedIdentityAndIsOneUse() {
val state = TrailerReturnState(clock)
val saved = snapshot()
assertEquals(identity, state.restore(saved, title))
assertFalse(state.consumeRestoredSession(current))
state.confirmRestore(current)
assertTrue(state.consumeRestoredSession(current))
assertFalse(state.consumeRestoredSession(current))
}
@Test fun confirmationCannotDismissTheChooserForAnotherOrNowProtectedPerson() {
for (person in listOf(user.copy(id = UUID.randomUUID()), user.copy(pin = "URDL"), user.copy(requireLogin = true))) {
val state = TrailerReturnState(clock)
state.restore(snapshot(), title)
val changed = current.copy(user = person)
state.confirmRestore(changed)
assertFalse(state.consumeRestoredSession(changed))
}
}
@Test fun aNormalResumeStopsLaterUnrelatedSavesFromReusingTheHandoff() {
val state = TrailerReturnState(clock)
state.prepare(current, title)
state.finishHandoff()
val saved = Bundle().also { state.save(it, current, title) }
assertNull(TrailerReturnState(clock).restore(saved, title))
}
@Test fun aRecreatedActivityResumeDoesNotDiscardItsValidatedRestoration() {
val state = TrailerReturnState(clock)
state.restore(snapshot(), title)
state.finishHandoff()
assertTrue(state.isRestoring(identity))
state.confirmRestore(current)
assertTrue(state.consumeRestoredSession(current))
}
}

View file

@ -113,6 +113,7 @@ class UpstreamContentFocusTest {
val model = mockk<WolfskinShellViewModel>(relaxed = true) val model = mockk<WolfskinShellViewModel>(relaxed = true)
every { model.session } returns WolfskinSession().also { it.state = state } every { model.session } returns WolfskinSession().also { it.state = state }
every { model.conductarr } returns ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()) every { model.conductarr } returns ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore())
every { model.updates } returns org.justinvisser.wolfskin.tv.update.FakeUpdateController()
every { model.libraryDestinations } returns emptyMap() every { model.libraryDestinations } returns emptyMap()
// An unavailable saved identity uses the real sign-in placeholder, avoiding any server reads. // An unavailable saved identity uses the real sign-in placeholder, avoiding any server reads.
every { model.signedInPerson } returns "unavailable" every { model.signedInPerson } returns "unavailable"

View file

@ -115,13 +115,13 @@ class WatchReadTest {
val cache = mockk<HomeLibraryCache>(relaxed = true) val cache = mockk<HomeLibraryCache>(relaxed = true)
every { cache.read(any()) } returns (HomeLibrary(resume = listOf(HomeTitle(resume.id.toString(), "Cached episode", "episode", resumeSeconds = 10.0))) to 1L) 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 session = ConductarrSession(MemoryCacheStore())
val model = WolfskinHomeViewModel(RuntimeEnvironment.getApplication(), facade, cache, NavigationManager(), WolfskinSeeAllLists(), session) val model = WolfskinHomeViewModel(RuntimeEnvironment.getApplication(), facade, cache, NavigationManager(), WolfskinSeeAllLists(), session, WolfskinSession())
try { try {
val loading = launch { model.loadLibrary(server, user, person) } val loading = launch { model.loadLibrary(server, user, person) }
runCurrent() runCurrent()
assertFalse(loading.isCompleted) assertFalse(loading.isCompleted)
assertTrue(model.snapshot.rows.first().watchReady) assertTrue(model.snapshot.rows.first().watchReady)
assertEquals(1136.274, model.snapshot.rows.single { it.id == "nu" }.cards.single().resumeSeconds, .0001) assertEquals(1136.274, model.snapshot.rows.single { it.id == "cw" }.cards.single().resumeSeconds, .0001)
releaseLibrary.complete(Unit) releaseLibrary.complete(Unit)
loading.join() loading.join()
assertEquals(96.078, model.snapshot.rows.first().cards.first().resumeSeconds, .0001) assertEquals(96.078, model.snapshot.rows.first().cards.first().resumeSeconds, .0001)
@ -132,4 +132,28 @@ class WatchReadTest {
WholphinDispatchers.reset() WholphinDispatchers.reset()
} }
} }
@Test fun mergedHomeUsesSeriesWatchActivityNotAnUnwatchedNextEpisodeDate() = runTest {
val otherSeries = UUID.fromString("00000000-0000-0000-0000-000000000004")
val checkpoint = decode(resumeWire).let { it.copy(seriesId = seriesId, seriesName = "Series A",
userData = it.userData!!.copy(lastPlayedDate = java.time.LocalDateTime.parse("2026-11-05T12:00:00"))) }
val hero = checkpoint.copy(id = UUID.fromString("00000000-0000-0000-0000-000000000005"), type = BaseItemKind.MOVIE, seriesId = null, seriesName = null)
val next = decode(nextWire).let { it.copy(seriesId = seriesId, seriesName = "Series A",
userData = it.userData!!.copy(playbackPositionTicks = 0, lastPlayedDate = null)) }
val otherNext = next.copy(id = UUID.fromString("00000000-0000-0000-0000-000000000006"), seriesId = otherSeries, seriesName = "Series B")
coEvery { items.getResumeItems(userId = userId, fields = any(), mediaTypes = any(), limit = 20) } returns successQueryResult(listOf(hero, checkpoint))
coEvery { shows.getNextUp(userId = userId, fields = any(), limit = 20, enableUserData = true) } returns successQueryResult(listOf(next, otherNext))
for ((id, date) in listOf(seriesId to "2026-11-04T12:00:00", otherSeries to "2026-11-07T12:00:00")) {
coEvery { library.getItem(userId = userId, itemId = id) } returns successResponse(BaseItemDto(id = id, type = BaseItemKind.SERIES))
val watched = checkpoint.copy(userData = checkpoint.userData!!.copy(lastPlayedDate = java.time.LocalDateTime.parse(date)))
coEvery { items.getItems(userId = userId, parentId = id, recursive = true,
includeItemTypes = listOf(BaseItemKind.EPISODE), isPlayed = true, enableUserData = true,
sortBy = listOf(org.jellyfin.sdk.model.api.ItemSortBy.DATE_PLAYED),
sortOrder = listOf(org.jellyfin.sdk.model.api.SortOrder.DESCENDING), limit = 1) } returns successQueryResult(listOf(watched))
}
val rows = buildHomeRows(HomeReadFacade(api, images, mockk()).readWatch(userId), emptyList())
val watching = rows.single { it.id == "cw" }.cards
assertEquals(listOf(otherNext.id.toString(), checkpoint.id.toString()), watching.map { it.id })
assertEquals(96.078, watching.last().resumeSeconds, .0001)
}
} }

View file

@ -47,7 +47,7 @@ class WatchReturnTest {
val cache = mockk<HomeLibraryCache>(relaxed = true) val cache = mockk<HomeLibraryCache>(relaxed = true)
every { cache.read(any()) } returns null every { cache.read(any()) } returns null
val session = ConductarrSession(MemoryCacheStore()) val session = ConductarrSession(MemoryCacheStore())
val model = WolfskinHomeViewModel(rule.activity, facade, cache, NavigationManager(), WolfskinSeeAllLists(), session) val model = WolfskinHomeViewModel(rule.activity, facade, cache, NavigationManager(), WolfskinSeeAllLists(), session, WolfskinSession())
val oldFixtures = ConductarrPreference.fixtures val oldFixtures = ConductarrPreference.fixtures
ConductarrPreference.updateFixtures(false) ConductarrPreference.updateFixtures(false)
try { try {

View file

@ -0,0 +1,147 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SeerrRatingsService
import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.components.LocalSeerrRatingsService
import com.github.damontecres.wholphin.ui.main.settings.HomePageSettingsState
import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsPage
import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsViewModel
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.setup.InstallUpdatePage
import com.github.damontecres.wholphin.ui.setup.InstallUpdateState
import com.github.damontecres.wholphin.ui.setup.UpdateViewModel
import com.github.damontecres.wholphin.util.LoadingState
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.*
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.design.*
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** Real full-screen pages keep their local Back ladder before task-level exit. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class WolfskinBackStackTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private lateinit var activity: ComponentActivity
private val navigation = NavigationManager()
private val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null)
private val shell = ShellState(listOf(ShellPerson("justin", "Justin", "J", true)), "justin") {
when (it) {
ShellAction.Pop -> navigation.goBack()
ShellAction.Exit -> navigation.requestExit()
else -> Unit
}
}.apply { pickerVisible = false; pickerLaunch = false; navFocused = true }
@Before fun initialize() { activity = rule.activity }
@Test fun singletonUpdatesBackFinishesInsteadOfBecomingADeadEnd() {
navigation.replace(Destination.UpdateApp)
val model = updatesModel()
rule.setContent {
rememberWolfskinBackHandler(shell, navigation)
WolfskinTheme { InstallUpdatePage(preferences, viewModel = model) }
}
rule.onNodeWithText("No release found! Check the update URL.").assertIsDisplayed()
back()
assertTrue(activity.isFinishing)
assertEquals(listOf(Destination.UpdateApp), navigation.backStack.toList())
}
@Test fun pushedUpdatesPopsSettingsThenPickerBeforeRootExit() {
navigation.navigateTo(Destination.Wolfskin("settings"))
navigation.navigateTo(Destination.UpdateApp)
shell.enterDestination("settings")
shell.navFocused = false
val model = updatesModel()
rule.setContent {
rememberWolfskinBackHandler(shell, navigation)
WolfskinTheme {
if (navigation.backStack.last() == Destination.UpdateApp) InstallUpdatePage(preferences, viewModel = model)
else ShellSurface(shell) { SettingsList(shell, it) }
}
}
rule.onNodeWithText("No release found! Check the update URL.").assertIsDisplayed()
back()
assertFalse(activity.isFinishing)
assertEquals(listOf(Destination.Home(), Destination.Wolfskin("settings")), navigation.backStack.toList())
back()
assertFalse(activity.isFinishing)
assertTrue(shell.pickerVisible)
assertEquals(listOf(Destination.Home()), navigation.backStack.toList())
back()
assertFalse(shell.pickerVisible)
assertTrue(shell.navFocused)
assertFalse(activity.isFinishing)
back()
assertTrue(activity.isFinishing)
}
@Test fun unsavedHomeSettingsPromptsBeforeDiscardCanExitTheTask() {
navigation.replace(Destination.HomeSettings)
val model = mockk<HomeSettingsViewModel>(relaxed = true)
every { model.state } returns MutableStateFlow(HomePageSettingsState.EMPTY.copy(loading = LoadingState.Success))
every { model.serverPluginActive } returns MutableStateFlow(false)
every { model.settingsChanged } returns MutableStateFlow(true)
every { model.navigationManager } returns navigation
every { model.discardChanges() } returns Job().apply { complete() }
val images = mockk<ImageUrlService>(relaxed = true)
val ratings = SeerrRatingsService(mockk(relaxed = true), mockk(relaxed = true))
rule.setContent {
rememberWolfskinBackHandler(shell, navigation)
WolfskinTheme {
CompositionLocalProvider(LocalSeerrRatingsService provides ratings, LocalImageUrlService provides images) {
HomeSettingsPage(preferences, Modifier.fillMaxSize(), viewModel = model)
}
}
}
back()
assertFalse("Back must not discard unsaved settings or finish the task", activity.isFinishing)
assertEquals(listOf(Destination.HomeSettings), navigation.backStack.toList())
val discard = rule.onNodeWithText("Discard").assertIsDisplayed().assertIsEnabled()
discard.performSemanticsAction(SemanticsActions.RequestFocus) { it() }
discard.assertIsFocused().performKeyInput { pressKey(Key.DirectionCenter) }
rule.waitUntil(10_000) {
Snapshot.sendApplyNotifications()
Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
activity.isFinishing
}
assertEquals(listOf(Destination.HomeSettings), navigation.backStack.toList())
}
private fun updatesModel(): UpdateViewModel = mockk<UpdateViewModel>(relaxed = true).also { model ->
every { model.state } returns MutableStateFlow(InstallUpdateState(loading = LoadingState.Success))
every { model.navigationManager } returns navigation
every { model.updater.hasPermissions() } returns true
}
private fun back() {
rule.runOnIdle { activity.onBackPressedDispatcher.onBackPressed() }
rule.mainClock.advanceTimeBy(400)
if (!activity.isFinishing) rule.waitForIdle()
}
}

View file

@ -0,0 +1,193 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import android.os.Build
import androidx.activity.ComponentActivity
import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
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 com.github.damontecres.wholphin.ui.nav.Destination
import io.mockk.coEvery
import com.github.takahirom.roborazzi.captureRoboImage
import java.io.File
import io.mockk.mockk
import java.util.UUID
import kotlinx.coroutines.cancel
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
import org.justinvisser.wolfskin.design.*
import org.justinvisser.wolfskin.tv.read.LibraryReadFacade
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** Actual Wolfskin NavDisplay and Android Back finish the activity instead of exposing upstream setup. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34, 36], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class WolfskinRootExitTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private lateinit var activity: ComponentActivity
private lateinit var model: WolfskinShellViewModel
private lateinit var conductarr: ConductarrSession
private val navigation = NavigationManager()
private val session = WolfskinSession()
private var oldFixtures = false
private var oldScreens = true
@Before fun initialize() {
activity = rule.activity
ConductarrPreference.initialize(activity)
WolfskinScreenPreference.initialize(activity)
oldFixtures = ConductarrPreference.fixtures
oldScreens = WolfskinScreenPreference.enabled
ConductarrPreference.updateFixtures(true)
WolfskinScreenPreference.enabled = true
}
@After fun cleanup() {
if (::model.isInitialized) model.viewModelScope.cancel()
if (::conductarr.isInitialized) conductarr.close()
ConductarrPreference.updateFixtures(oldFixtures)
WolfskinScreenPreference.enabled = oldScreens
}
@Test fun homeContentThenNavBackFinishesTheActivity() = verifyRoot("home")
@Test fun discoverContentThenNavBackFinishesTheActivity() = verifyRoot("discover")
@Test fun moviesContentThenNavBackFinishesTheActivity() = verifyRoot("movies")
@Test fun showsContentThenNavBackFinishesTheActivity() = verifyRoot("shows")
@Test fun animeContentThenNavBackFinishesTheActivity() = verifyRoot("anime")
@Test fun hardwareBackUsesTheSameContentNavExitLadder() {
mount()
val shell = session.state!!
rule.runOnIdle { shell.pickerVisible = false; shell.pickerLaunch = false; shell.navFocused = false; shell.view.row = 1 }
rule.runOnIdle { activity.dispatchKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_DOWN, android.view.KeyEvent.KEYCODE_BACK)) }
assertFalse("Native Back key-down must not run ahead of system Back completion", shell.navFocused)
rule.runOnIdle { activity.dispatchKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_UP, android.view.KeyEvent.KEYCODE_BACK)) }
assertTrue(shell.navFocused)
assertFalse(activity.isFinishing)
val captures = File(System.getProperty("wolfskin.captureDirectory"), "system-back").apply { mkdirs() }
rule.onRoot().captureRoboImage(File(captures, "sdk${Build.VERSION.SDK_INT}-home-nav.png").absolutePath)
rule.runOnIdle {
activity.dispatchKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_DOWN, android.view.KeyEvent.KEYCODE_BACK))
activity.dispatchKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_UP, android.view.KeyEvent.KEYCODE_BACK))
}
rule.mainClock.advanceTimeBy(400)
assertTrue(activity.isFinishing)
assertEquals(listOf(Destination.Home()), navigation.backStack.toList())
}
@Test fun coldPickerBackFinishesWithoutSelectingAnAvatarOrShowingUpstreamUi() {
mount()
assertTrue(session.state!!.pickerLaunch)
back()
assertTrue("Back from the cold picker must finish the activity", activity.isFinishing)
assertEquals(listOf(Destination.Home()), navigation.backStack.toList())
}
@Test fun reopenedPickerBackDismissesToNavAndTheFollowingBackExits() {
mount()
val shell = session.state!!
rule.runOnIdle { shell.pickerVisible = false; shell.focusNav(shell.tabs.size); shell.key(ShellKey.Ok) }
assertTrue(shell.pickerVisible)
back()
assertFalse(shell.pickerVisible)
assertTrue(shell.navFocused)
assertFalse(activity.isFinishing)
back()
assertTrue(activity.isFinishing)
}
@Test fun comingInSheetDismissesBeforeContentNavAndExit() {
mount()
val shell = session.state!!
rule.runOnIdle {
shell.pickerVisible = false
shell.pickerLaunch = false
shell.navFocused = false
shell.openRequests()
}
assertTrue(shell.requestsVisible)
back()
assertFalse(shell.requestsVisible)
assertFalse(shell.navFocused)
assertFalse(activity.isFinishing)
back()
assertTrue(shell.navFocused)
assertFalse(activity.isFinishing)
back()
assertTrue(activity.isFinishing)
}
private fun verifyRoot(route: String) {
mount()
val shell = session.state!!
val destination = if (route == "home") Destination.Home() else Destination.Wolfskin(route)
rule.runOnIdle {
shell.pickerVisible = false
shell.pickerLaunch = false
shell.enterDestination(route)
navigation.replace(destination)
}
rule.mainClock.advanceTimeBy(1200)
rule.waitForIdle()
rule.waitUntil(10_000) {
Snapshot.sendApplyNotifications()
Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
shell.contentFocus?.hasContent == true &&
(route !in LIBRARY_DESTINATIONS || shell.rememberedView(route).library?.status == LibraryStatus.Ready)
}
rule.runOnIdle { shell.navFocused = false }
back()
assertFalse("The first Back must not finish $route content", activity.isFinishing)
assertTrue("The first Back must move $route content to nav", shell.navFocused)
assertEquals(listOf(destination), navigation.backStack.toList())
back()
assertTrue("Back from $route nav must finish the activity", activity.isFinishing)
assertEquals("Exit cannot detour to old Home", listOf(destination), navigation.backStack.toList())
}
private fun mount() {
val server = JellyfinServer(UUID.randomUUID(), "Fixture", "https://example.invalid", null)
val user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = null)
val repository = mockk<ServerRepository>(relaxed = true)
coEvery { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, listOf(user))
val libraries = mockk<LibraryReadFacade>(relaxed = true)
coEvery { libraries.readDestinations(user.id) } returns emptyMap()
conductarr = ConductarrSession(MemoryCacheStore())
model = WolfskinShellViewModel(activity, session, repository, navigation, mockk(relaxed = true),
mockk(relaxed = true), libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController(), trailerReturn = mockk(relaxed = true))
val home = HomeFixture(activity).snapshot("justin")
rule.setContent {
WolfskinApp(server, user, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null), viewModel = model,
homeContent = { shell, _, _, modifier -> HomeScreen(shell, home, modifier) {} })
}
rule.waitUntil(10_000) {
Snapshot.sendApplyNotifications()
Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
session.state?.people?.size == 3 && model.signedInPerson == "justin"
}
rule.waitForIdle()
}
private fun back() {
rule.runOnIdle { activity.onBackPressedDispatcher.onBackPressed() }
rule.mainClock.advanceTimeBy(400)
if (!activity.isFinishing) rule.waitForIdle()
}
}

View file

@ -0,0 +1,209 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv
import android.app.Application
import android.graphics.BitmapFactory
import android.content.Intent
import android.os.Looper
import android.view.View
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.snapshots.Snapshot
import org.robolectric.Shadows
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.datastore.core.DataStore
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.async
import kotlinx.coroutines.Dispatchers
import com.github.damontecres.wholphin.MainActivityViewModel
import com.github.damontecres.wholphin.MainContent
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.CurrentUser
import com.github.damontecres.wholphin.data.RestoredSession
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.*
import com.github.takahirom.roborazzi.captureRoboImage
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.coVerify
import java.io.File
import java.io.IOException
import java.util.UUID
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
import org.robolectric.RobolectricTestRunner
import org.justinvisser.wolfskin.design.WolfskinTokens
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** Exercise the actual MainContent boundary, not a parallel copy of its destination mapping. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
class WolfskinSetupNavigationTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private val server = JellyfinServer(UUID.randomUUID(), "Household", "https://jellyfin.test", null)
private val user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = "test-token")
private val repository = mockk<ServerRepository>(relaxed = true)
private val navigation = NavigationManager()
private val setup = mockk<SetupNavigationManager>(relaxed = true)
private val stack = mutableStateListOf<SetupDestination>(SetupDestination.Loading)
private val conductarr = ConductarrSession(MemoryCacheStore())
private val model = WolfskinSetupViewModel(repository, navigation, setup, WolfskinSession(), conductarr)
private val preferences = mockk<DataStore<AppPreferences>>()
private val intentService = mockk<IntentService>()
private var previousScreens = true
private var previousFixtures = false
@Before fun configure() {
WolfskinScreenPreference.initialize(rule.activity)
previousScreens = WolfskinScreenPreference.enabled
WolfskinScreenPreference.enabled = true
ConductarrPreference.initialize(rule.activity)
previousFixtures = ConductarrPreference.fixtures
ConductarrPreference.updateFixtures(false)
every { repository.current } returns MutableStateFlow<CurrentUser?>(null)
coEvery { repository.getMostRecentServer() } returns RestoredSession.ServerOnly(server)
every { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, listOf(user))
every { repository.serverDao.getServers() } returns listOf(JellyfinServerUsers(server, listOf(user)))
every { setup.navigateTo(any()) } answers { stack[0] = firstArg() }
every { preferences.data } returns MutableStateFlow(AppPreferences.getDefaultInstance())
rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
@Suppress("DEPRECATION")
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
}
@After fun close() {
conductarr.close()
WolfskinScreenPreference.enabled = previousScreens
ConductarrPreference.updateFixtures(previousFixtures)
}
private fun mount() {
val screensaver = mockk<ScreensaverService>(relaxed = true)
every { screensaver.state } returns MutableStateFlow(ScreensaverState(false, false, false, false))
rule.setContent {
MainContent(stack, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null),
mockk(relaxed = true), screensaver, liveSession = repository.current, trailerReturn = mockk(relaxed = true), modifier = Modifier,
setupInterceptor = { destination, modifier -> WolfskinSetupContent(destination, modifier, model) })
}
}
private fun appModel() = MainActivityViewModel(rule.activity, preferences, repository, setup, navigation,
mockk(relaxed = true), mockk(relaxed = true), mockk(relaxed = true), intentService, trailerReturn = mockk(relaxed = true))
private fun awaitStage(stage: WolfskinSetupViewModel.Stage) {
rule.waitUntil(5_000) {
Snapshot.sendApplyNotifications()
Shadows.shadowOf(Looper.getMainLooper()).idle()
model.stage == stage
}
}
private fun startApp() {
val startupModel = appModel()
val result = rule.activity.lifecycleScope.async(Dispatchers.Default) { startupModel.handleAppStart() }
rule.waitUntil(15_000) {
Shadows.shadowOf(Looper.getMainLooper()).idle()
result.isCompleted
}
runBlocking { result.await() }
}
private fun assertOwnPicker() {
awaitStage(WolfskinSetupViewModel.Stage.Picker)
rule.onNodeWithText("Who’s watching?").assertIsDisplayed()
rule.onNodeWithText("Justin").assertIsDisplayed()
rule.onNodeWithText(rule.activity.getString(R.string.select_server)).assertDoesNotExist()
rule.onNodeWithText(rule.activity.getString(R.string.select_user)).assertDoesNotExist()
}
@Test fun coldStartLegacySignOutDrawerSwitchAndIntentFailureNeverComposeStockSelectors() {
startApp()
mount()
assertOwnPicker()
// The legacy sign-out-equivalent erased the recent preference, but Room still has the household.
coEvery { repository.getMostRecentServer() } returns RestoredSession.None
rule.runOnIdle { setup.navigateTo(SetupDestination.ServerList) }
assertOwnPicker()
rule.runOnIdle { setup.navigateTo(SetupDestination.UserList(server)) }
assertOwnPicker()
coEvery { intentService.parseIntent(any()) } returns IntentResult.Error("Cannot restore sign-in")
rule.runOnIdle { rule.activity.lifecycleScope.launch { appModel().handleIntent(Intent()) } }
rule.waitUntil(5_000) { stack.last() == SetupDestination.ServerList }
assertOwnPicker()
coVerify(exactly = 0) { repository.switchServerOrUser() }
}
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Test fun coldStartSavedPeopleKeepTheirOwnColorsWithoutConductarr() {
val saved = listOf(user, user.copy(id = UUID.randomUUID(), name = "Mindel"),
user.copy(id = UUID.randomUUID(), name = "Shared"))
every { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, saved)
startApp()
mount()
assertOwnPicker()
saved.forEach { rule.onNodeWithText(it.name!!).assertIsDisplayed() }
rule.mainClock.advanceTimeBy(1_000)
rule.waitForIdle()
val people = model.picker!!.people
val bounds = people.map { rule.onNodeWithTag("picker-person:${it.key}").fetchSemanticsNode().boundsInRoot }
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "setup").apply { mkdirs() }
val frame = File(directory, "cold-start-people.png")
rule.onRoot().captureRoboImage(frame)
val bitmap = BitmapFactory.decodeFile(frame.absolutePath)
try {
val fills = bounds.map { bitmap.getPixel(it.center.x.toInt(), (it.top + it.width / 5).toInt()) }
assertEquals(listOf(WolfskinTokens.PersonColor.justin.toArgb(),
WolfskinTokens.PersonColor.mindel.toArgb(), WolfskinTokens.PersonColor.shared.toArgb()), fills)
} finally {
bitmap.recycle()
}
assertNull(conductarr.identity.value)
assertEquals(saved.map { it.id.toString() }, people.map { it.jellyfinUserId })
}
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Test fun unreachableSelectionRendersWolfskinRecoveryFrame() {
stack[0] = SetupDestination.UserList(server)
coEvery { repository.changeUser(server, user) } throws IOException("offline")
mount()
assertOwnPicker()
rule.onNodeWithText("Justin").performClick()
awaitStage(WolfskinSetupViewModel.Stage.Unavailable)
rule.onNodeWithText("Try again").assertIsDisplayed()
rule.onNodeWithText("Sign in").assertIsDisplayed()
rule.onNodeWithText(rule.activity.getString(R.string.select_user)).assertDoesNotExist()
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "setup").apply { mkdirs() }
rule.onRoot().captureRoboImage(File(directory, "household-unreachable.png"))
}
@Test fun unconfiguredFirstRunOffersUrlInsteadOfStockServerSelector() {
coEvery { repository.getMostRecentServer() } returns RestoredSession.None
every { repository.serverDao.getServers() } returns emptyList()
stack[0] = SetupDestination.ServerList
mount()
awaitStage(WolfskinSetupViewModel.Stage.Unconfigured)
rule.onNodeWithText("Connect your household server").assertIsDisplayed()
rule.onNodeWithTag("household-server-url").assertIsDisplayed().performTextInput("https://jellyfin.test")
rule.onNodeWithText("Try again").assertIsEnabled()
rule.onNodeWithText(rule.activity.getString(R.string.select_server)).assertDoesNotExist()
}
}

View file

@ -0,0 +1,122 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv
import android.app.Application
import android.content.pm.ActivityInfo
import android.os.Looper
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.test.core.app.ApplicationProvider
import com.github.damontecres.wholphin.MainContent
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.ScreensaverService
import com.github.damontecres.wholphin.services.ScreensaverState
import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.test.TestActivity
import com.github.damontecres.wholphin.ui.TestModule
import com.github.damontecres.wholphin.ui.successResponse
import com.github.damontecres.wholphin.util.WholphinDispatchers
import com.github.damontecres.wholphin.util.configure
import com.github.damontecres.wholphin.util.reset
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import dagger.hilt.android.testing.HiltTestApplication
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import java.util.UUID
import javax.inject.Inject
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.quickConnectApi
import org.jellyfin.sdk.api.client.extensions.systemApi
import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.api.operations.QuickConnectApi
import org.jellyfin.sdk.api.operations.SystemApi
import org.jellyfin.sdk.api.operations.UserApi
import org.jellyfin.sdk.model.api.PublicSystemInfo
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestWatcher
import org.junit.runner.Description
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
/** The rollout switch must still expose both real upstream selectors, not a second forked copy. */
@OptIn(ExperimentalCoroutinesApi::class)
@HiltAndroidTest
@Config(application = HiltTestApplication::class, sdk = [34])
@RunWith(RobolectricTestRunner::class)
class WolfskinSetupRollbackTest {
@get:Rule(order = 0) val hilt = HiltAndroidRule(this)
@get:Rule(order = 1) val registerActivity = object : TestWatcher() {
override fun starting(description: Description?) {
val context = ApplicationProvider.getApplicationContext<Application>()
shadowOf(context.packageManager).addOrUpdateActivity(ActivityInfo().apply {
name = TestActivity::class.java.name
packageName = context.packageName
})
}
}
@get:Rule(order = 2) val rule = createAndroidComposeRule<TestActivity>()
@Inject lateinit var api: ApiClient
@Inject lateinit var jellyfin: Jellyfin
private var previousScreens = true
@Before fun setup() {
hilt.inject()
WholphinDispatchers.configure(TestModule.testDispatcher)
WolfskinScreenPreference.initialize(rule.activity)
previousScreens = WolfskinScreenPreference.enabled
WolfskinScreenPreference.enabled = false
val users = mockk<UserApi>()
val system = mockk<SystemApi>()
val quickConnect = mockk<QuickConnectApi>()
every { jellyfin.createApi(any(), any(), any(), any(), any()) } returns api
every { api.userApi } returns users
every { api.systemApi } returns system
every { api.quickConnectApi } returns quickConnect
coEvery { users.getPublicUsers() } returns successResponse(emptyList())
coEvery { system.getPublicSystemInfo() } returns successResponse(PublicSystemInfo(version = "10.11.11"))
coEvery { quickConnect.getQuickConnectEnabled() } returns successResponse(false)
}
@After fun restore() {
WolfskinScreenPreference.enabled = previousScreens
WholphinDispatchers.reset()
}
@Test fun rollbackRendersBothStockSelectors() {
val stack = mutableStateListOf<SetupDestination>(SetupDestination.ServerList)
val screensaver = mockk<ScreensaverService>()
every { screensaver.state } returns MutableStateFlow(ScreensaverState(false, false, false, false))
rule.setContent {
MainContent(stack, NavigationManager(), UserPreferences(AppPreferences.getDefaultInstance(), null),
mockk(relaxed = true), screensaver, liveSession = MutableStateFlow(null), trailerReturn = mockk(relaxed = true))
}
fun awaitHeading(resource: Int) {
rule.waitUntil(5_000) {
TestModule.testDispatcher.scheduler.advanceUntilIdle()
shadowOf(Looper.getMainLooper()).idle()
rule.onAllNodes(androidx.compose.ui.test.hasText(rule.activity.getString(resource))).fetchSemanticsNodes().isNotEmpty()
}
rule.onNodeWithText(rule.activity.getString(resource)).assertIsDisplayed()
rule.onNodeWithText("Who’s watching?").assertDoesNotExist()
}
awaitHeading(R.string.select_server)
rule.runOnIdle { stack[0] = SetupDestination.UserList(JellyfinServer(UUID.randomUUID(), "Household", "https://jellyfin.test", null)) }
awaitHeading(R.string.select_user)
}
}

View file

@ -0,0 +1,238 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv
import android.app.Application
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import com.github.damontecres.wholphin.data.AppDatabase
import com.github.damontecres.wholphin.data.CurrentUser
import com.github.damontecres.wholphin.data.MostRecentServer
import com.github.damontecres.wholphin.data.MostRecentServerSharedPreferences
import com.github.damontecres.wholphin.data.ServerRepository
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.services.SetupDestination
import com.github.damontecres.wholphin.services.SetupNavigationManager
import com.github.damontecres.wholphin.ui.successResponse
import com.github.damontecres.wholphin.ui.TestModule
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import java.io.IOException
import java.util.UUID
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.async
import kotlinx.coroutines.test.runTest
import org.jellyfin.sdk.model.ClientInfo
import org.jellyfin.sdk.model.DeviceInfo
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.InvalidStatusException
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.discovery.RecommendedServerInfo
import org.jellyfin.sdk.discovery.RecommendedServerInfoScore
import org.jellyfin.sdk.model.api.AuthenticationResult
import org.jellyfin.sdk.model.api.PublicSystemInfo
import org.jellyfin.sdk.model.api.UserDto
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.tv.WolfskinSetupViewModel.Stage
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/** Setup preserves saved credentials, rejects protected-profile bypasses and recovers real auth failures. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class)
class WolfskinSetupTest {
private val context get() = ApplicationProvider.getApplicationContext<Application>()
private val database by lazy { Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java).allowMainThreadQueries().build() }
private val recent by lazy { MostRecentServerSharedPreferences(context) }
private val server = JellyfinServer(UUID.randomUUID(), "Household", "https://jellyfin.test", null)
private var user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = "saved-test-token")
private val api = mockk<ApiClient>(relaxed = true)
private val usersApi = mockk<UserApi>()
private val systemApi = mockk<SystemApi>()
private val jellyfin by lazy { TestModule.jellyfin(mockk(), context, ClientInfo("Wolfskin test", "test"), DeviceInfo("setup-test", "Setup test")) }
private val navigation = NavigationManager()
private val setup = mockk<SetupNavigationManager>(relaxed = true)
private val session = WolfskinSession()
private val conductarr = ConductarrSession(MemoryCacheStore())
private val repository by lazy { ServerRepository(context, jellyfin, database.serverDao(), api, Dispatchers.IO, recent) }
private val model by lazy { WolfskinSetupViewModel(repository, navigation, setup, session, conductarr) }
private val dto get() = UserDto(id = user.id, name = user.name, hasPassword = true, hasConfiguredPassword = true, hasConfiguredEasyPassword = false)
private var previousFixtures = false
@Before fun saveHousehold(): Unit = runBlocking {
ConductarrPreference.initialize(context)
previousFixtures = ConductarrPreference.fixtures
ConductarrPreference.updateFixtures(false)
recent.clear()
database.serverDao().addOrUpdateServer(server)
user = database.serverDao().addOrUpdateUser(user)
recent.save(CurrentUser(server, user))
val client = jellyfin
every { api.userApi } returns usersApi
every { api.systemApi } returns systemApi
every { client.createApi(any(), any(), any(), any(), any()) } returns api
coEvery { usersApi.getCurrentUser() } returns successResponse(dto)
coEvery { systemApi.getPublicSystemInfo() } returns successResponse(PublicSystemInfo(id = server.id.toString(), serverName = server.name))
}
@After fun close() {
conductarr.close()
database.close()
ConductarrPreference.updateFixtures(previousFixtures)
}
@Test fun enteringPickerPreservesServerAndUserAndRecoversErasedPreferenceFromRoom() = runBlocking {
val saved = recent.get()
model.load(null)
assertEquals(Stage.Picker, model.stage)
assertEquals(user.id.toString(), model.picker!!.person.jellyfinUserId)
assertEquals(saved, recent.get())
recent.clear()
model.load(null)
assertEquals(server, model.server)
assertEquals(Stage.Picker, model.stage)
assertEquals(MostRecentServer.None, recent.get())
}
@Test fun expiredTokenRequiresCredentialsAndSuccessfulLoginRestoresSessionWithoutSecondPicker() = runBlocking {
model.load(server)
coEvery { usersApi.getCurrentUser() } throws InvalidStatusException(401)
model.select(model.picker!!.person)
assertEquals(Stage.Login, model.stage)
assertEquals(user, model.loginUser)
assertNull(repository.current.value)
assertTrue(recent.get() is MostRecentServer.ServerAndUser)
coEvery { usersApi.authenticateUserByName(any()) } returns successResponse(AuthenticationResult(user = dto, serverId = server.id.toString(), accessToken = "renewed-test-token"))
coEvery { usersApi.getCurrentUser() } returns successResponse(dto)
model.login(user.name!!, "test-password")
assertEquals("renewed-test-token", repository.current.value!!.user.accessToken)
assertEquals(user.id, (recent.get() as MostRecentServer.ServerAndUser).userId)
assertFalse(session.state!!.pickerVisible)
assertEquals(user.id.toString(), session.state!!.person.jellyfinUserId)
}
@Test fun unreachableServerOffersRetryAndSavedPersonCanSignInWhenItReturns() = runTest {
model.load(server)
val saved = recent.get()
coEvery { usersApi.getCurrentUser() } throws IOException("offline")
model.select(model.picker!!.person)
assertEquals(Stage.Unavailable, model.stage)
assertEquals(saved, recent.get())
val retry = async(start = CoroutineStart.UNDISPATCHED) { model.retry() }
assertEquals(Stage.Loading, model.stage)
retry.await()
assertEquals(Stage.Picker, model.stage)
coEvery { usersApi.getCurrentUser() } returns successResponse(dto)
model.select(model.picker!!.person)
assertEquals(user.id, repository.current.value!!.user.id)
assertFalse(session.state!!.pickerVisible)
}
@Test fun retryCanRemainUnavailableWithoutErasingHousehold() = runTest {
database.serverDao().deleteUser(server.id, user.id)
val saved = recent.get()
coEvery { usersApi.getPublicUsers() } throws IOException("offline")
model.load(null)
assertEquals(Stage.Unavailable, model.stage)
val retry = async(start = CoroutineStart.UNDISPATCHED) { model.retry() }
assertEquals(Stage.Loading, model.stage)
retry.await()
assertEquals(Stage.Unavailable, model.stage)
assertEquals(server, model.server)
assertEquals(saved, recent.get())
}
@Test fun pinAndRequireLoginCannotReuseSavedTokenWithoutUnlocking() = runBlocking {
user = user.copy(pin = "URDL")
database.serverDao().updateUser(user)
model.load(server)
model.select(model.picker!!.person)
assertEquals(user, model.pinUser)
assertTrue(model.picker!!.pickerVisible)
model.enterPin("WRONG")
assertNull(repository.current.value)
model.enterPin("URDL")
assertEquals(user.id, repository.current.value!!.user.id)
user = user.copy(pin = null, requireLogin = true)
database.serverDao().updateUser(user)
model.load(server)
model.select(model.picker!!.person)
assertEquals(Stage.Login, model.stage)
assertEquals(user, model.loginUser)
}
@Test fun unavailablePublicProfilesKeepHouseholdAndAllowCredentialRecovery() = runBlocking {
database.serverDao().deleteUser(server.id, user.id)
coEvery { usersApi.getPublicUsers() } throws IOException("offline")
model.load(null)
assertEquals(Stage.Unavailable, model.stage)
assertEquals(server, model.server)
model.showLogin(null)
coEvery { usersApi.authenticateUserByName(any()) } returns successResponse(AuthenticationResult(
user = dto, serverId = server.id.toString(), accessToken = "new-test-token"))
model.login(user.name!!, "test-password")
assertEquals(user.id, repository.current.value!!.user.id)
}
@Test fun expiredLaunchRecoveryIgnoresOutagesLateIdentitiesAndRollbackMode() = runBlocking {
WolfskinScreenPreference.initialize(context)
val previous = WolfskinScreenPreference.enabled
try {
WolfskinScreenPreference.enabled = true
val expected = (repository.restoreSavedSession() as com.github.damontecres.wholphin.data.RestoredSession.Success).currentUser
var destination: SetupDestination = SetupDestination.AppContent(expected)
every { setup.navigateTo(any()) } answers { destination = firstArg() }
recoverWolfskinSession(context, IOException("offline"), expected, repository, setup)
assertTrue(destination is SetupDestination.AppContent)
WolfskinScreenPreference.enabled = false
recoverWolfskinSession(context, InvalidStatusException(401), expected, repository, setup)
assertTrue(destination is SetupDestination.AppContent)
WolfskinScreenPreference.enabled = true
val other = database.serverDao().addOrUpdateUser(user.copy(rowId = 0, id = UUID.randomUUID(), name = "Mindel"))
recent.save(CurrentUser(server, other))
repository.restoreSavedSession()
recoverWolfskinSession(context, InvalidStatusException(401), expected, repository, setup)
assertTrue(destination is SetupDestination.AppContent)
recoverWolfskinSession(context, InvalidStatusException(401), repository.current.value, repository, setup)
model.load((destination as SetupDestination.UserList).server)
assertEquals(Stage.Picker, model.stage)
assertTrue(model.picker!!.people.any { it.jellyfinUserId == other.id.toString() })
assertEquals(other.id, (recent.get() as MostRecentServer.ServerAndUser).userId)
} finally { WolfskinScreenPreference.enabled = previous }
}
@Test fun firstRunDiscoversAndPersistsHouseholdBeforeFetchingPublicProfiles() = runBlocking {
database.serverDao().deleteUser(server.id, user.id)
database.serverDao().deleteServer(server.id)
recent.clear()
model.load(null)
assertEquals(Stage.Unconfigured, model.stage)
coEvery { jellyfin.discovery.getRecommendedServers("bad-url") } returns emptyList()
model.connect("bad-url")
assertEquals(Stage.Unconfigured, model.stage)
assertTrue(database.serverDao().getServers().isEmpty())
coEvery { jellyfin.discovery.getRecommendedServers(server.url) } returns listOf(RecommendedServerInfo(
address = server.url, responseTime = 1, score = RecommendedServerInfoScore.GREAT, issues = emptyList(),
systemInfo = Result.success(PublicSystemInfo(id = server.id.toString(), serverName = server.name, startupWizardCompleted = true))))
model.connect(server.url)
assertEquals(server.id, database.serverDao().getServers().single().server.id)
coEvery { usersApi.getPublicUsers() } returns successResponse(listOf(dto))
model.load(model.server)
assertEquals(Stage.Picker, model.stage)
model.select(model.picker!!.person)
assertEquals(Stage.Login, model.stage)
assertNull(repository.current.value)
}
}

View file

@ -92,6 +92,8 @@ class UpdatesKeysTest {
launch(UpdateUiState.Checking, settings = true) launch(UpdateUiState.Checking, settings = true)
repeat(9) { press(listOf(Key.DirectionDown)) } repeat(9) { press(listOf(Key.DirectionDown)) }
assertEquals(9, shell.settingsRow) assertEquals(9, shell.settingsRow)
// Visual settling precedes the spring's idle state and final row boundary.
rule.mainClock.advanceTimeUntil(timeoutMillis = 320) { !shell.view.listState.isScrollInProgress }
assertEquals(4, shell.view.listState.firstVisibleItemIndex) assertEquals(4, shell.view.listState.firstVisibleItemIndex)
capture("classic") capture("classic")
press(listOf(Key.DirectionCenter)) press(listOf(Key.DirectionCenter))
@ -104,10 +106,10 @@ class UpdatesKeysTest {
assertTrue(shell.contentOverlayVisible) assertTrue(shell.contentOverlayVisible)
assertFalse(shell.navFocused) assertFalse(shell.navFocused)
assertTrue(controller.autoCheck.value) assertTrue(controller.autoCheck.value)
press(listOf(Key.Back)) press(listOf(Key.Escape))
assertFalse(shell.contentOverlayVisible) assertFalse(shell.contentOverlayVisible)
assertEquals(1, (shell.contentFocus as UpdateFocus).actionIndex) assertEquals(1, (shell.contentFocus as UpdateFocus).actionIndex)
press(listOf(Key.Back)) press(listOf(Key.Escape))
assertEquals("settings", shell.route) assertEquals("settings", shell.route)
assertEquals(1, shell.settingsRow) assertEquals(1, shell.settingsRow)
assertEquals(0, shell.view.listState.firstVisibleItemIndex) assertEquals(0, shell.view.listState.firstVisibleItemIndex)
@ -120,7 +122,7 @@ class UpdatesKeysTest {
assertTrue(range.value() > 0f) assertTrue(range.value() > 0f)
assertFalse(shell.navFocused) assertFalse(shell.navFocused)
assertFalse((shell.contentFocus as UpdateFocus).autoFocused) assertFalse((shell.contentFocus as UpdateFocus).autoFocused)
press(listOf(Key.Back)) press(listOf(Key.Escape))
assertEquals(1, (shell.contentFocus as UpdateFocus).actionIndex) assertEquals(1, (shell.contentFocus as UpdateFocus).actionIndex)
} }

View file

@ -115,10 +115,16 @@ private fun Title.titleItem(provider: ConductarrProvider): TitleItem = TitleItem
otherOwners = provider.holders(this).toList(), tmdbId = tmdbId, otherOwners = provider.holders(this).toList(), tmdbId = tmdbId,
) )
internal fun TitlePageData.requestTitle(viewer: ShellPerson): DiscoverTitle = DiscoverTitle( internal fun TitlePageData.requestTitle(viewer: ShellPerson): DiscoverTitle? {
item.tmdbId ?: item.id.substringAfter(':').toLongOrNull() ?: error("Request needs a catalogue identity"), if (item.kind == "movie") "movie" else "tv", item.name, item.year, if (item.kind != "movie" && item.kind != "tv") return null
genres = item.genres, overview = item.overview, poster = item.poster, backdrop = item.backdrop, val tmdb = item.tmdbId?.takeIf { it > 0 }
runtimeSeconds = item.runtimeSeconds.toInt(), seasonCount = item.seasons.takeIf { it > 0 }, ?: item.id.takeIf { it.startsWith("${item.kind}:") }?.substringAfter(':')?.toLongOrNull()?.takeIf { it > 0 }
seasons = seasonNumbers.filter { it > 0 }.takeIf { it.isNotEmpty() }, ratings = item.ratings.map { DiscoverRating(it.source, it.score, it.label) }, ?: return null
heldBy = item.otherOwners.toSet() + if (item.held) setOf(viewer.key) else emptySet(), return DiscoverTitle(
) tmdb, item.kind, item.name, item.year,
genres = item.genres, overview = item.overview, poster = item.poster, backdrop = item.backdrop,
runtimeSeconds = item.runtimeSeconds.toInt(), seasonCount = item.seasons.takeIf { it > 0 },
seasons = seasonNumbers.filter { it > 0 }.takeIf { it.isNotEmpty() }, ratings = item.ratings.map { DiscoverRating(it.source, it.score, it.label) },
heldBy = item.otherOwners.toSet() + if (item.held) setOf(viewer.key) else emptySet(),
)
}

View file

@ -23,6 +23,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.ensureActive
import org.justinvisser.wolfskin.tv.read.playbackPositionMs import org.justinvisser.wolfskin.tv.read.playbackPositionMs
import org.justinvisser.wolfskin.design.* import org.justinvisser.wolfskin.design.*
import org.justinvisser.wolfskin.tv.read.HomeLibraryCache import org.justinvisser.wolfskin.tv.read.HomeLibraryCache
@ -37,6 +38,7 @@ class WolfskinHomeViewModel @Inject constructor(
private val navigation: NavigationManager, private val navigation: NavigationManager,
private val seeAll: WolfskinSeeAllLists, private val seeAll: WolfskinSeeAllLists,
private val conductarr: ConductarrSession, private val conductarr: ConductarrSession,
val session: WolfskinSession,
) : ViewModel() { ) : ViewModel() {
val conductarrState get() = conductarr.identity val conductarrState get() = conductarr.identity
val audiences get() = home.audiences val audiences get() = home.audiences
@ -49,6 +51,15 @@ class WolfskinHomeViewModel @Inject constructor(
private var loadRevision = 0L private var loadRevision = 0L
private var catalogueSnapshot = DiscoverSnapshot() private var catalogueSnapshot = DiscoverSnapshot()
private var publishCatalogue: ((DiscoverSnapshot) -> Unit)? = null private var publishCatalogue: ((DiscoverSnapshot) -> Unit)? = null
private var playbackJob: kotlinx.coroutines.Job? = null
private var playbackRevision = 0L
/** Leaving Home invalidates late preparation results as well as a pending black handoff. */
fun cancelPlayback() {
playbackRevision++
playbackJob?.cancel()
session.playback.fail()
}
suspend fun load(server: JellyfinServer, user: JellyfinUser, person: ShellPerson, retry: Boolean = false) { suspend fun load(server: JellyfinServer, user: JellyfinUser, person: ShellPerson, retry: Boolean = false) {
catalogueJob?.cancel() catalogueJob?.cancel()
@ -164,23 +175,45 @@ class WolfskinHomeViewModel @Inject constructor(
HomeAction.Retry -> viewModelScope.launch { load(server, user, shell.person, retry = true) } HomeAction.Retry -> viewModelScope.launch { load(server, user, shell.person, retry = true) }
is HomeAction.SeeAll -> if (action.row.target in shell.tabs) openRoot(shell, action.row.target!!) else openCatalogueGrid(action.row, shell) is HomeAction.SeeAll -> if (action.row.target in shell.tabs) openRoot(shell, action.row.target!!) else openCatalogueGrid(action.row, shell)
is HomeAction.Details -> navigation.navigateTo(Destination.Wolfskin(titleRoute(action.title.id))) is HomeAction.Details -> navigation.navigateTo(Destination.Wolfskin(titleRoute(action.title.id)))
is HomeAction.Play -> viewModelScope.launch { is HomeAction.Play -> startPlayback(action.title, shell, server, user)
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) private fun startPlayback(title: HomeTitle, shell: ShellState, server: JellyfinServer, user: JellyfinUser) {
if (!snapshot.jellyfinAvailable || !matchesUser) { val start = session.playback
showToast(RecoveryMessage.PlaybackUnavailable) if (start.starting != null) return
return@launch playbackJob?.cancel()
} val revision = ++playbackRevision
try { val personKey = shell.person.key
val item = withContext(WholphinDispatchers.IO) { home.preparePlayback(user.id, action.title) } start.begin("hero-play")
if (item == null) showToast(RecoveryMessage.PlaybackUnavailable) playbackJob = viewModelScope.launch {
else if (shell.person.key == personKey && (sourceScope == null || sourceScope == "${server.id}:${user.id}")) navigation.navigateTo(Destination.Playback(item.id, item.playbackPositionMs())) if (revision != playbackRevision || start.starting == null) return@launch
} catch (exception: Exception) { val selectedId = shell.person.jellyfinUserId
if (exception is CancellationException) throw exception val matchesUser = if (selectedId != null) selectedId == user.id.toString() else
personKey == user.name.orEmpty().lowercase() || (conductarr.identity.value?.person == "${server.id}:${user.id}" && personKey == conductarr.identity.value?.operator?.requester)
if (!snapshot.jellyfinAvailable || !matchesUser) {
start.fail()
showToast(RecoveryMessage.PlaybackUnavailable)
return@launch
}
try {
val item = withContext(WholphinDispatchers.IO) { home.preparePlayback(user.id, title) }
coroutineContext.ensureActive()
if (revision != playbackRevision || start.starting == null) return@launch
if (item == null) {
start.fail()
showToast(RecoveryMessage.PlaybackUnavailable) showToast(RecoveryMessage.PlaybackUnavailable)
} else if (shell.person.key == personKey && (sourceScope == null || sourceScope == "${server.id}:${user.id}")) {
start.ready {
if (revision == playbackRevision && shell.person.key == personKey) navigation.navigateTo(Destination.Playback(item.id, item.playbackPositionMs()))
}
} else start.fail()
} catch (exception: Exception) {
if (revision == playbackRevision && start.starting != null) {
start.fail()
if (exception !is CancellationException) showToast(RecoveryMessage.PlaybackUnavailable)
} }
if (exception is CancellationException) throw exception
} }
} }
} }
@ -234,6 +267,7 @@ fun HomeDestination(shell: ShellState, server: JellyfinServer, user: JellyfinUse
val identity by model.conductarrState.collectAsState() val identity by model.conductarrState.collectAsState()
val watchRefresh = rememberWatchRefresh("${server.id}:${user.id}") 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) } LaunchedEffect(shell.person, server.id, user.id, identity?.client, identity?.signedIn, model.audiences, watchRefresh) { model.load(server, user, shell.person) }
DisposableEffect(shell.person.key, server.id, user.id) { onDispose { model.cancelPlayback() } }
val view = shell.rememberedView("home") val view = shell.rememberedView("home")
LaunchedEffect(view.row, model.snapshot.rows) { LaunchedEffect(view.row, model.snapshot.rows) {
val lane = model.snapshot.rows.drop(view.row).take(3).lastOrNull { it.catalogue }?.id val lane = model.snapshot.rows.drop(view.row).take(3).lastOrNull { it.catalogue }?.id

View file

@ -4,8 +4,10 @@ import android.content.Context
import android.net.Uri import android.net.Uri
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -14,15 +16,19 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.type import androidx.compose.ui.input.key.type
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.compose.LifecycleResumeEffect
import org.justinvisser.wolfskin.design.DiscoverOverlay import org.justinvisser.wolfskin.design.DiscoverOverlay
import org.justinvisser.wolfskin.design.ShellKey import org.justinvisser.wolfskin.design.ShellKey
import org.justinvisser.wolfskin.design.isScreenEntryActive
import org.justinvisser.wolfskin.design.readShellKey import org.justinvisser.wolfskin.design.readShellKey
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.BuildConfig import com.github.damontecres.wholphin.BuildConfig
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.preferences.ThemeSongVolume
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.ThemeSongPlayer
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
@ -65,6 +71,8 @@ class WolfskinTitleViewModel @Inject constructor(
private val navigation: NavigationManager, private val navigation: NavigationManager,
val conductarr: ConductarrSession, val conductarr: ConductarrSession,
private val repository: ServerRepository, private val repository: ServerRepository,
val themeSongPlayer: ThemeSongPlayer,
private val trailerReturn: TrailerReturnState,
) : ViewModel() { ) : ViewModel() {
var title by mutableStateOf<TitleState?>(null) var title by mutableStateOf<TitleState?>(null)
private set private set
@ -77,22 +85,42 @@ class WolfskinTitleViewModel @Inject constructor(
private var personReadPending = false private var personReadPending = false
private var personReadFinished = false private var personReadFinished = false
private var loadJob: Job? = null private var loadJob: Job? = null
private var playbackJob: Job? = null
private var playbackGeneration = 0
private val seasonEpisodes = mutableMapOf<Int, List<TitleItem>>()
private val saveMutex = Mutex() private val saveMutex = Mutex()
private var performerPage = 1 private var performerPage = 1
var conductarrAction: ((TitleAction) -> Unit)? = null var conductarrAction: ((TitleAction) -> Unit)? = null
val watchScope: String get() = "${repository.currentServer?.id}:${repository.currentUser?.id}" val watchScope: String get() = "${repository.currentServer?.id}:${repository.currentUser?.id}"
fun openRoute(route: String) = navigation.navigateTo(Destination.Wolfskin(route)) fun openRoute(route: String) {
fun popRoute() = navigation.goBack() cancelPlayback()
navigation.navigateTo(Destination.Wolfskin(route))
}
fun popRoute() {
cancelPlayback()
navigation.goBack()
}
/** Preparation belongs to the active entry, including when Back closes an overlay. */
internal fun cancelPlayback() {
playbackJob?.cancel()
playbackJob = null
title?.playbackStart?.fail()
}
private val fixtureSelections by lazy { context.getSharedPreferences("wolfskin-title-fixture", Context.MODE_PRIVATE) } private val fixtureSelections by lazy { context.getSharedPreferences("wolfskin-title-fixture", Context.MODE_PRIVATE) }
/** Only an explicit preview/test choice admits assets; real catalogue ids use Conductarr. */ /** Only an explicit preview/test choice admits assets; real catalogue ids use Conductarr. */
fun load(destination: String, selected: ShellPerson, userPreferences: UserPreferences, fixtures: Boolean = BuildConfig.DEBUG, force: Boolean = false) { fun load(destination: String, selected: ShellPerson, userPreferences: UserPreferences, fixtures: Boolean = BuildConfig.DEBUG, force: Boolean = false) {
if (!force && route == destination && ::viewer.isInitialized && viewer.key == selected.key) return if (!force && route == destination && ::viewer.isInitialized && viewer.key == selected.key) return
if (force && route == destination && ::viewer.isInitialized && viewer.key == selected.key && val sameTitle = route == destination && ::viewer.isInitialized && viewer.key == selected.key && !destination.startsWith("person/")
title?.data?.status == TitleReadStatus.Ready && title?.data?.item?.held == true && if (!sameTitle) {
(title?.data?.connection != TitleConnection.SignedIn || conductarr.identity.value?.signedIn == true) && cancelPlayback()
!destination.startsWith("person/") && fixture == null) { seasonEpisodes.clear()
}
val readyTitle = sameTitle && title?.data?.status == TitleReadStatus.Ready
val fixtureRefresh = title?.takeIf { force && readyTitle && fixture != null && fixtures && BuildConfig.DEBUG }
if (force && readyTitle && fixture == null &&
(title?.data?.connection != TitleConnection.SignedIn || conductarr.identity.value?.signedIn == true)) {
loadJob?.cancel() loadJob?.cancel()
preferences = userPreferences preferences = userPreferences
title?.let { it.update(it.data.copy(watchReady = false)) } title?.let { it.update(it.data.copy(watchReady = false)) }
@ -102,14 +130,19 @@ class WolfskinTitleViewModel @Inject constructor(
route = destination route = destination
loadJob?.cancel() loadJob?.cancel()
person = null person = null
fixture = null if (fixtureRefresh == null) fixture = null
personReadPending = false personReadPending = false
personReadFinished = false personReadFinished = false
performerPage = 1 performerPage = 1
viewer = selected viewer = selected
preferences = userPreferences preferences = userPreferences
val id = Uri.decode(destination.substringAfter('/')) val id = Uri.decode(destination.substringAfter('/'))
title = TitleState(TitlePageData(TitleItem(id, "", if (destination.startsWith("person/")) "person" else "movie"), status = TitleReadStatus.Loading), viewer, ::handle) if (fixtureRefresh == null) {
cancelPlayback()
// Losing authorization clears the old data, but is not another first-load skeleton.
val status = if (sameTitle && title?.data?.status != TitleReadStatus.Loading) TitleReadStatus.Unavailable else TitleReadStatus.Loading
title = TitleState(TitlePageData(TitleItem(id, "", if (destination.startsWith("person/")) "person" else "movie"), status = status), viewer, ::handle)
}
loadJob = viewModelScope.launch { loadJob = viewModelScope.launch {
if (fixtures && BuildConfig.DEBUG && id.toUuidOrNull() == null) { if (fixtures && BuildConfig.DEBUG && id.toUuidOrNull() == null) {
val source = fixtureProvider.read() val source = fixtureProvider.read()
@ -121,18 +154,34 @@ class WolfskinTitleViewModel @Inject constructor(
val data = withContext(Dispatchers.IO) { source.title(id, selected.key) } val data = withContext(Dispatchers.IO) { source.title(id, selected.key) }
val key = "${viewer.key}:${data.item.id}" val key = "${viewer.key}:${data.item.id}"
val watchKey = "${viewer.key}:${data.watchTarget.id}" val watchKey = "${viewer.key}:${data.watchTarget.id}"
title = TitleState(data.copy( val restored = data.copy(
selectedVersion = fixtureSelections.getString("$key:version", null), selectedVersion = fixtureSelections.getString("$key:version", null),
selectedAudio = if (fixtureSelections.contains("$key:audio")) fixtureSelections.getInt("$key:audio", -1) else null, selectedAudio = if (fixtureSelections.contains("$key:audio")) fixtureSelections.getInt("$key:audio", -1) else null,
selectedSubtitle = fixtureSelections.getInt("$key:subtitle", -1), selectedSubtitle = fixtureSelections.getInt("$key:subtitle", -1),
watchTarget = data.watchTarget.copy(watched = fixtureSelections.getBoolean("$watchKey:watched", data.watchTarget.watched)), watchTarget = data.watchTarget.copy(watched = fixtureSelections.getBoolean("$watchKey:watched", data.watchTarget.watched)),
hidden = fixtureSelections.getBoolean("$key:hidden", data.hidden), hidden = fixtureSelections.getBoolean("$key:hidden", data.hidden),
), viewer, ::handle) )
if (fixtureRefresh != null) {
if (title === fixtureRefresh) updateTitle(fixtureRefresh, restored)
} else {
title = TitleState(restored, viewer, ::handle)
rememberSeason(restored)
}
} }
} else if (destination.startsWith("person/")) readPerson(id) else readTitle(id) } else if (destination.startsWith("person/")) readPerson(id) else readTitle(id)
} }
} }
private fun rememberSeason(data: TitlePageData) {
if (data.episodesStatus == TitleReadStatus.Ready) data.selectedSeason?.let { seasonEpisodes[it] = data.episodes }
}
private fun updateTitle(current: TitleState, data: TitlePageData) {
if (title !== current) return
current.update(data)
rememberSeason(data)
}
private suspend fun readTitle(id: String, retry: Boolean = false) { private suspend fun readTitle(id: String, retry: Boolean = false) {
val current = title ?: return val current = title ?: return
val uuid = id.toUuidOrNull() val uuid = id.toUuidOrNull()
@ -143,9 +192,9 @@ class WolfskinTitleViewModel @Inject constructor(
val heldId = uuid ?: catalogue?.let { facade.findHeld(it[1].toLong(), it[0]) } val heldId = uuid ?: catalogue?.let { facade.findHeld(it[1].toLong(), it[0]) }
if (title !== current) return if (title !== current) return
if (heldId != null) { if (heldId != null) {
if (current.data.status != TitleReadStatus.Ready) facade.readSaved(heldId, preferences)?.let { current.update(it) } if (current.data.status != TitleReadStatus.Ready) facade.readSaved(heldId, preferences)?.let { updateTitle(current, it) }
held = facade.read(heldId, current.requestedSeason, preferences) held = facade.read(heldId, current.requestedSeason, preferences)
current.update(held) updateTitle(current, held)
} }
} catch (exception: Exception) { } catch (exception: Exception) {
if (exception is CancellationException) throw exception if (exception is CancellationException) throw exception
@ -160,7 +209,7 @@ class WolfskinTitleViewModel @Inject constructor(
if (identity != null && tmdb != null) { if (identity != null && tmdb != null) {
val media = if ((catalogue?.get(0) ?: held?.item?.kind) == "movie") MediaType.MOVIE else MediaType.TV val media = if ((catalogue?.get(0) ?: held?.item?.kind) == "movie") MediaType.MOVIE else MediaType.TV
ConductarrTitlePages(identity).title(tmdb, media, held).collect { data -> ConductarrTitlePages(identity).title(tmdb, media, held).collect { data ->
if (title === current && conductarr.identity.value?.client === identity.client && conductarr.identity.value?.signedIn == true) current.update(data) if (title === current && conductarr.identity.value?.client === identity.client && conductarr.identity.value?.signedIn == true) updateTitle(current, data)
} }
} else if (uuid == null) current.update(current.data.copy(status = TitleReadStatus.Unavailable, connection = TitleConnection.SignedOut, requestsAllowed = false)) } else if (uuid == null) current.update(current.data.copy(status = TitleReadStatus.Unavailable, connection = TitleConnection.SignedOut, requestsAllowed = false))
} }
@ -231,11 +280,21 @@ class WolfskinTitleViewModel @Inject constructor(
private fun newPerson(actor: org.justinvisser.wolfskin.design.TitlePerson, credits: List<TitleItem>) = private fun newPerson(actor: org.justinvisser.wolfskin.design.TitlePerson, credits: List<TitleItem>) =
PersonState(actor, credits, ::handle) { viewModelScope.launch { readPerson(actor.id) } } PersonState(actor, credits, ::handle) { viewModelScope.launch { readPerson(actor.id) } }
/** A callback retained during exit must hand Back to the live destination, not pop its old title again. */
internal inline fun handleBack(route: String, shell: ShellState, onCurrentEntry: () -> Unit) {
if (navigation.backStack.last().toWolfskinRoute() == route) onCurrentEntry()
else handleWolfskinBack(shell, navigation)
}
private fun handle(action: TitleAction) { private fun handle(action: TitleAction) {
when (action) { when (action) {
TitleAction.Pop -> navigation.goBack() TitleAction.Pop -> popRoute()
is TitleAction.Person -> navigation.navigateTo(action.person.id.toUuidOrNull()?.let { Destination.MediaItem(it, org.jellyfin.sdk.model.api.BaseItemKind.PERSON) } ?: Destination.Wolfskin(personRoute(action.person.id))) is TitleAction.Person -> {
cancelPlayback()
navigation.navigateTo(action.person.id.toUuidOrNull()?.let { Destination.MediaItem(it, org.jellyfin.sdk.model.api.BaseItemKind.PERSON) } ?: Destination.Wolfskin(personRoute(action.person.id)))
}
is TitleAction.OpenTitle -> { is TitleAction.OpenTitle -> {
cancelPlayback()
val destination = action.item.id.toUuidOrNull()?.let { Destination.MediaItem(it, when (action.item.kind) { val destination = action.item.id.toUuidOrNull()?.let { Destination.MediaItem(it, when (action.item.kind) {
"movie" -> org.jellyfin.sdk.model.api.BaseItemKind.MOVIE "movie" -> org.jellyfin.sdk.model.api.BaseItemKind.MOVIE
"episode" -> org.jellyfin.sdk.model.api.BaseItemKind.EPISODE "episode" -> org.jellyfin.sdk.model.api.BaseItemKind.EPISODE
@ -247,22 +306,36 @@ class WolfskinTitleViewModel @Inject constructor(
is TitleAction.Play -> { is TitleAction.Play -> {
val opened = title?.data?.watchTarget val opened = title?.data?.watchTarget
play((if (action.restart || opened?.kind != "tv") action.item.id else opened.id).toUuidOrNull(), play((if (action.restart || opened?.kind != "tv") action.item.id else opened.id).toUuidOrNull(),
restart = action.restart) restart = action.restart, control = if (action.restart) "restart" else "play")
} }
is TitleAction.Trailer -> if (action.url.startsWith("jellyfin:")) play(action.url.removePrefix("jellyfin:").toUuidOrNull(), restart = true) is TitleAction.Trailer -> if (action.url.startsWith("jellyfin:")) play(action.url.removePrefix("jellyfin:").toUuidOrNull(), restart = true)
else if (!TitleTrailer(context).open(action.url)) title?.toast = "No YouTube app is available to play this trailer." else {
themeSongPlayer.stop()
val launched = TitleTrailer(context).open(action.url) {
trailerReturn.prepare(repository.current.value, navigation.backStack.lastOrNull())
}
if (!launched) {
trailerReturn.clear()
title?.toast = "No YouTube app is available to play this trailer."
}
}
is TitleAction.Version, is TitleAction.Track, is TitleAction.Watched -> savePlayback(action) is TitleAction.Version, is TitleAction.Track, is TitleAction.Watched -> savePlayback(action)
is TitleAction.Season -> { is TitleAction.Season -> {
val current = title ?: return
loadJob?.cancel() loadJob?.cancel()
val cached = seasonEpisodes[action.number]
if (cached == null) current.showSeasonLoading() else current.showSeason(action.number, cached)
loadJob = viewModelScope.launch { loadJob = viewModelScope.launch {
val current = title ?: return@launch
current.showSeasonLoading()
val source = fixture val source = fixture
if (source == null) readTitle(Uri.decode(route.substringAfter('/'))) if (source == null) readTitle(Uri.decode(route.substringAfter('/')))
else { else try {
val data = withContext(Dispatchers.IO) { source.title(Uri.decode(route.substringAfter('/')), viewer.key, action.number) } val data = withContext(Dispatchers.IO) { source.title(Uri.decode(route.substringAfter('/')), viewer.key, action.number) }
current.update(data.copy(selectedVersion = current.versionId, selectedAudio = current.audioIndex, updateTitle(current, data.copy(selectedVersion = current.versionId, selectedAudio = current.audioIndex,
selectedSubtitle = current.subtitleIndex, watchTarget = current.data.watchTarget.copy(watched = current.watched), hidden = current.data.hidden)) selectedSubtitle = current.subtitleIndex, watchTarget = current.data.watchTarget.copy(watched = current.watched), hidden = current.data.hidden))
} catch (exception: Exception) {
if (exception is CancellationException) throw exception
if (current.data.episodesStatus == TitleReadStatus.Loading) current.restoreSeason()
current.toast = RecoveryMessage.StillUnreachable
} }
} }
} }
@ -281,18 +354,33 @@ class WolfskinTitleViewModel @Inject constructor(
} }
} }
private fun play(id: UUID?, restart: Boolean) { private fun play(id: UUID?, restart: Boolean, control: String = "restart") {
val current = title val current = title ?: return
if (current.playbackStart.starting != null) return
val generation = ++playbackGeneration
playbackJob?.cancel()
current.playbackStart.begin(control)
val playbackScope = watchScope val playbackScope = watchScope
viewModelScope.launch { playbackJob = viewModelScope.launch {
try { try {
if (id == null) throw IllegalArgumentException("No Jellyfin playback identity") if (id == null) throw IllegalArgumentException("No Jellyfin playback identity")
val item = facade.preparePlayback(id) ?: throw IllegalArgumentException("No playable item") val item = facade.preparePlayback(id) ?: throw IllegalArgumentException("No playable item")
if (title !== current || watchScope != playbackScope) return@launch if (generation != playbackGeneration) return@launch
navigation.navigateTo(Destination.Playback(item.id, if (restart) 0L else item.playbackPositionMs())) if (title !== current || watchScope != playbackScope || current.playbackStart.starting == null) {
current.playbackStart.fail()
return@launch
}
current.playbackStart.ready {
if (generation == playbackGeneration && title === current && watchScope == playbackScope) {
themeSongPlayer.stop()
navigation.navigateTo(Destination.Playback(item.id, if (restart) 0L else item.playbackPositionMs()))
}
}
} catch (exception: Exception) { } catch (exception: Exception) {
val active = generation == playbackGeneration && current.playbackStart.starting != null
if (active) current.playbackStart.fail()
if (exception is CancellationException) throw exception if (exception is CancellationException) throw exception
title?.toast = RecoveryMessage.PlaybackUnavailable if (active && title === current && watchScope == playbackScope) current.toast = RecoveryMessage.PlaybackUnavailable
} }
} }
} }
@ -334,15 +422,30 @@ fun TitleDestination(route: String, shell: ShellState, preferences: UserPreferen
val watchRefresh = rememberWatchRefresh(viewModel.watchScope) 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) } LaunchedEffect(route, shell.person.key, identity?.client, identity?.signedIn, fixtures, watchRefresh) { viewModel.load(route, shell.person, preferences, fixtures, force = true) }
val title = viewModel.title?.takeIf { it.person.key == shell.person.key } val title = viewModel.title?.takeIf { it.person.key == shell.person.key }
val active = isScreenEntryActive()
DisposableEffect(viewModel, title, active) {
if (!active) viewModel.cancelPlayback()
onDispose { viewModel.cancelPlayback() }
}
TitleThemeMusic(title?.data?.item, preferences, viewModel.themeSongPlayer,
active = active && route.startsWith("title/") && title?.data?.status == TitleReadStatus.Ready,
owner = "${shell.person.key}:${viewModel.watchScope}")
val requests = if (route.startsWith("title/")) rememberTitleRequests(viewModel, shell, route, identity, fixtures, title) else null val requests = if (route.startsWith("title/")) rememberTitleRequests(viewModel, shell, route, identity, fixtures, title) else null
LaunchedEffect(title?.data?.status) { LaunchedEffect(title?.data?.status) {
if (title?.data?.status == TitleReadStatus.Ready) shell.navFocused = false if (title?.data?.status == TitleReadStatus.Ready) shell.navFocused = false
} }
val person = viewModel.person.takeIf { title != null } val person = viewModel.person.takeIf { title != null }
BackHandler(enabled = title != null) { BackHandler(enabled = active && title != null) {
if (requests?.overlay != null) requests.key(ShellKey.Back) else title?.back(shell) if (title?.playbackStart?.launch == null) {
viewModel.cancelPlayback()
viewModel.handleBack(route, shell) {
if (requests?.overlay != null) requests.key(ShellKey.Back) else title?.back(shell)
}
}
} }
Box(modifier.onPreviewKeyEvent { event -> Box(modifier.onPreviewKeyEvent { event ->
if (title?.playbackStart?.launch != null) return@onPreviewKeyEvent true
if (readShellKey(event) == ShellKey.Back && event.type == KeyEventType.KeyDown && event.nativeKeyEvent.repeatCount == 0) viewModel.cancelPlayback()
if (requests?.overlay == null || shell.pickerVisible) false else readShellKey(event)?.let { if (requests?.overlay == null || shell.pickerVisible) false else readShellKey(event)?.let {
if (event.type == KeyEventType.KeyDown && event.nativeKeyEvent.repeatCount == 0) requests.key(it) if (event.type == KeyEventType.KeyDown && event.nativeKeyEvent.repeatCount == 0) requests.key(it)
true true
@ -354,4 +457,19 @@ fun TitleDestination(route: String, shell: ShellState, preferences: UserPreferen
} }
} }
/** Only the resumed, current title entry owns theme playback; episodes borrow their series theme. */
@Composable
internal fun TitleThemeMusic(item: TitleItem?, preferences: UserPreferences, player: ThemeSongPlayer, active: Boolean, owner: String) {
val itemId = remember(item?.id, item?.seriesId, item?.held, item?.kind) {
item?.takeIf { it.held }?.let { (if (it.kind == "episode") it.seriesId else it.id)?.toUuidOrNull() }
}
val volume = preferences.appPreferences.interfacePreferences.playThemeSongs
if (active && itemId != null && volume != ThemeSongVolume.DISABLED && volume != ThemeSongVolume.UNRECOGNIZED) {
LifecycleResumeEffect(itemId, owner, volume) {
player.playThemeFor(itemId)
onPauseOrDispose { player.stop() }
}
}
}
private fun String.toUuidOrNull(): UUID? = try { UUID.fromString(this) } catch (_: IllegalArgumentException) { null } private fun String.toUuidOrNull(): UUID? = try { UUID.fromString(this) } catch (_: IllegalArgumentException) { null }

View file

@ -75,6 +75,7 @@ class TitleFixture(private val fixture: JSONObject) {
seriesName = source.optString("seriesTitle").takeIf { it.isNotEmpty() }, season = source.optInt("seasonNumber"), episode = source.optInt("episodeNumber"), seriesName = source.optString("seriesTitle").takeIf { it.isNotEmpty() }, season = source.optInt("seasonNumber"), episode = source.optInt("episodeNumber"),
ratings = ratings(source), otherOwners = owners.filter { it.getString("key") != person }.map { it.getString("name") }, ratings = ratings(source), otherOwners = owners.filter { it.getString("key") != person }.map { it.getString("name") },
isPosterBackdrop = source.optJSONObject("artwork")?.optString("backdropKind") == "poster_crop", isPosterBackdrop = source.optJSONObject("artwork")?.optString("backdropKind") == "poster_crop",
tmdbId = source.optLong("tmdbId").takeIf { it > 0 },
) )
} }

View file

@ -24,17 +24,20 @@ internal fun rememberTitleRequests(viewModel: WolfskinTitleViewModel, shell: She
} }
} }
SideEffect { if (!fixtures) requests?.updateRequesters(requesters) } SideEffect { if (!fixtures) requests?.updateRequesters(requesters) }
LaunchedEffect(requests, title?.data) { val requestItem = remember(fixtures, title?.data, shell.person.key) {
if (fixtures) null else title?.data?.requestTitle(shell.person)
}
LaunchedEffect(requests, title?.data, requestItem) {
val data = title?.data ?: return@LaunchedEffect val data = title?.data ?: return@LaunchedEffect
if (!fixtures && requests != null && data.item.tmdbId != null) { val item = requestItem ?: return@LaunchedEffect
val item = data.requestTitle(shell.person) if (requests != null) {
requests.updateSnapshot(DiscoverSnapshot(picks = if (data.pick) listOf(item) else emptyList(), requests.updateSnapshot(DiscoverSnapshot(picks = if (data.pick) listOf(item) else emptyList(),
hidden = if (data.hidden) listOf(item) else emptyList(), hidden = if (data.hidden) listOf(item) else emptyList(),
status = if (data.connection == TitleConnection.SignedOut) DiscoverStatus.NeedsSignIn else DiscoverStatus.Ready, status = if (data.connection == TitleConnection.SignedOut) DiscoverStatus.NeedsSignIn else DiscoverStatus.Ready,
savedAt = data.savedAt, reachable = data.connection == TitleConnection.SignedIn, requestsAllowed = data.requestsAllowed)) savedAt = data.savedAt, reachable = data.connection == TitleConnection.SignedIn, requestsAllowed = data.requestsAllowed))
} }
} }
val hidden = title?.data?.takeIf { it.item.tmdbId != null }?.let { data -> requests?.isHidden(data.requestTitle(shell.person)) } val hidden = requestItem?.let { requests?.isHidden(it) }
LaunchedEffect(requests, hidden, requests?.toast) { LaunchedEffect(requests, hidden, requests?.toast) {
val data = title?.data ?: return@LaunchedEffect val data = title?.data ?: return@LaunchedEffect
if (!fixtures && hidden != null && data.hidden != hidden) title.update(data.copy(hidden = hidden)) if (!fixtures && hidden != null && data.hidden != hidden) title.update(data.copy(hidden = hidden))
@ -49,12 +52,21 @@ internal fun rememberTitleRequests(viewModel: WolfskinTitleViewModel, shell: She
viewModel.conductarrAction = { action -> viewModel.conductarrAction = { action ->
val current = viewModel.title val current = viewModel.title
when (action) { when (action) {
is TitleAction.Request -> if (current != null && requests != null) requests.openRequest(action.forSomeoneElse, current.data.requestTitle(shell.person)) is TitleAction.Request -> if (current != null) {
is TitleAction.Requests -> shell.openRequests()
is TitleAction.Hide -> if (current != null && requests != null) {
val item = current.data.requestTitle(shell.person) val item = current.data.requestTitle(shell.person)
if (action.hidden) requests.hide(item) else requests.restore(item, showToast = false) if (item == null || requests == null) current.toast = RecoveryMessage.TitleActionUnavailable
requests.overlay = null // TitleState already owns the native hide receipt and Undo. else requests.openRequest(action.forSomeoneElse, item)
}
is TitleAction.Requests -> shell.openRequests()
is TitleAction.Hide -> if (current != null) {
val item = current.data.requestTitle(shell.person)
if (item == null || requests == null) {
current.update(current.data.copy(hidden = !action.hidden))
current.toast = RecoveryMessage.TitleActionUnavailable
} else {
if (action.hidden) requests.hide(item) else requests.restore(item, showToast = false)
requests.overlay = null // TitleState already owns the native hide receipt and Undo.
}
} }
else -> error("Not a Conductarr title action") else -> error("Not a Conductarr title action")
} }

View file

@ -9,7 +9,7 @@ import android.net.Uri
/** Only an installed app that handles the YouTube scheme may receive a remote trailer. */ /** Only an installed app that handles the YouTube scheme may receive a remote trailer. */
class TitleTrailer(private val context: Context) { class TitleTrailer(private val context: Context) {
/** A missing handler leaves the title in place rather than falling back to a browser. */ /** A missing handler leaves the title in place rather than falling back to a browser. */
fun open(url: String): Boolean { fun open(url: String, beforeLaunch: () -> Unit): Boolean {
val source = Uri.parse(url) val source = Uri.parse(url)
val video = when (source.host?.lowercase()) { val video = when (source.host?.lowercase()) {
"youtu.be" -> source.pathSegments.firstOrNull() "youtu.be" -> source.pathSegments.firstOrNull()
@ -17,11 +17,13 @@ class TitleTrailer(private val context: Context) {
?: source.pathSegments.takeIf { it.firstOrNull() in setOf("shorts", "embed") }?.getOrNull(1) ?: source.pathSegments.takeIf { it.firstOrNull() in setOf("shorts", "embed") }?.getOrNull(1)
else -> null else -> null
} ?: return false } ?: return false
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("vnd.youtube:$video")).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) // Authority form: SmartTube's IntentExtractor reads the id from Uri.getHost(), which is null for the opaque `vnd.youtube:ID`.
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("vnd.youtube://$video")).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
val handler = context.packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY) val handler = context.packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY)
.firstOrNull { it.activityInfo.exported } ?: return false .firstOrNull { it.activityInfo.exported } ?: return false
intent.setPackage(handler.activityInfo.packageName) intent.setPackage(handler.activityInfo.packageName)
return try { return try {
beforeLaunch()
context.startActivity(intent) context.startActivity(intent)
true true
} catch (_: ActivityNotFoundException) { } catch (_: ActivityNotFoundException) {

View file

@ -0,0 +1,154 @@
package org.justinvisser.wolfskin.tv
import android.content.Context
import android.os.Bundle
import android.os.SystemClock
import android.provider.Settings
import com.github.damontecres.wholphin.data.CurrentUser
import com.github.damontecres.wholphin.data.SavedSessionIdentity
import com.github.damontecres.wholphin.ui.nav.Destination
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
/** Elapsed time includes sleep; a missing boot counter cannot authorize task restoration. */
class TrailerReturnClock @Inject constructor(@param:ApplicationContext private val context: Context) {
/** A wall-clock adjustment must not extend the return window. */
fun readElapsedMillis(): Long = SystemClock.elapsedRealtime()
/** Older or restricted systems retain the picker when same-boot evidence is unavailable. */
fun readBootCount(): Int = try {
Settings.Global.getInt(context.contentResolver, Settings.Global.BOOT_COUNT, -1)
} catch (_: SecurityException) {
-1
}
}
/** Only an authenticated, unprotected trailer hand-off may supply a short-lived saved-task identity. */
@Singleton
class TrailerReturnState @Inject constructor(private val clock: TrailerReturnClock) {
private var pending: TrailerReturnRecord? = null
private var restoring: TrailerReturnRecord? = null
private var confirmed: TrailerReturnRecord? = null
/** Capture authorization before handing the activity to the external app; never capture a PIN login. */
@Synchronized
fun prepare(current: CurrentUser?, destination: Destination?) {
clear()
val route = titleDestination(destination) ?: return
if (current == null || current.user.isProtected || current.user.accessToken.isNullOrBlank()) return
val elapsed = clock.readElapsedMillis()
val boot = clock.readBootCount()
if (elapsed < 0 || boot < 0) return
pending = TrailerReturnRecord(current.server.id.toString(), current.user.id.toString(), route, elapsed, boot)
}
/** Snapshot before lifecycle cleanup; asynchronous startup must still recheck identity and expiry. */
@Synchronized
internal fun pendingHandoff(): TrailerReturnRecord? = pending
/** A live return has the same identity, title and deadline constraints as its saved task. */
@Synchronized
internal fun canContinue(record: TrailerReturnRecord?, current: CurrentUser?, destination: Destination?): Boolean =
record != null && matches(record, current) && record.route == titleDestination(destination) && isWithinWindow(record)
/** A task snapshot cannot carry an old identity after a person/protection/destination change. */
@Synchronized
fun save(outState: Bundle, current: CurrentUser?, destination: Destination?) {
outState.remove(SAVED_STATE_KEY)
val record = pending ?: return
if (!matches(record, current) || titleDestination(destination) != record.route || !isWithinWindow(record)) {
pending = null
return
}
outState.putString(SAVED_STATE_KEY, Json.encodeToString(record))
}
/** Saved navigation is insufficient without the matching, unexpired trailer hand-off record. */
@Synchronized
fun restore(savedState: Bundle?, destination: Destination?): SavedSessionIdentity? {
clear()
val encoded = savedState?.getString(SAVED_STATE_KEY) ?: return null
val record = try {
Json.decodeFromString<TrailerReturnRecord>(encoded)
} catch (_: SerializationException) {
return null
}
if (record.route != titleDestination(destination) || !isWithinWindow(record)) return null
val server = record.serverId.toUUIDOrNull() ?: return null
val user = record.userId.toUUIDOrNull() ?: return null
restoring = record
return SavedSessionIdentity(server, user)
}
/** The shell may skip its cold chooser only after the repository has accepted the exact identity. */
@Synchronized
fun confirmRestore(current: CurrentUser) {
val record = restoring
restoring = null
confirmed = record?.takeIf { matches(it, current) && isWithinWindow(it) }
}
@Synchronized
fun hasConfirmedRestore(): Boolean = confirmed != null
/** This one-use UI hand-off never authenticates anyone and cannot select a different person. */
@Synchronized
fun consumeRestoredSession(current: CurrentUser?): Boolean {
val record = confirmed
confirmed = null
return record != null && matches(record, current) && isWithinWindow(record)
}
/** A cancelled startup or slow lookup must not install credentials after its return window closes. */
@Synchronized
fun isRestoring(expected: SavedSessionIdentity): Boolean =
restoring?.let {
it.serverId == expected.serverId.toString() && it.userId == expected.userId.toString() && isWithinWindow(it)
} == true
/** Translucent handlers can return through onResume without onRestart; do not reuse their capture. */
@Synchronized
fun finishHandoff() {
pending = null
}
/** Normal return and explicit launches must not lend their marker to unrelated later saves. */
@Synchronized
fun clear() {
pending = null
restoring = null
confirmed = null
}
private fun matches(record: TrailerReturnRecord, current: CurrentUser?): Boolean =
current != null && !current.user.isProtected && !current.user.accessToken.isNullOrBlank() &&
current.server.id.toString() == record.serverId && current.user.id.toString() == record.userId
private fun isWithinWindow(record: TrailerReturnRecord): Boolean {
if (record.startedElapsed < 0 || record.bootCount < 0) return false
val age = clock.readElapsedMillis() - record.startedElapsed
return age >= 0 && age < RETURN_WINDOW_MILLIS && clock.readBootCount() == record.bootCount
}
private fun titleDestination(destination: Destination?): String? =
destination?.toWolfskinRoute()?.takeIf { it.startsWith("title/") }
companion object {
internal const val SAVED_STATE_KEY = "wolfskin.trailer.return"
private const val RETURN_WINDOW_MILLIS = 10L * 60L * 1000L
}
}
@Serializable
internal data class TrailerReturnRecord(
val serverId: String,
val userId: String,
val route: String,
val startedElapsed: Long,
val bootCount: Int,
)

View file

@ -2,17 +2,28 @@ package org.justinvisser.wolfskin.tv
import android.content.Context import android.content.Context
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.input.pointer.pointerInput
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@ -65,6 +76,8 @@ class WolfskinSession @Inject constructor() {
var serverId: String? = null var serverId: String? = null
internal var fixtureMode: Boolean? = null internal var fixtureMode: Boolean? = null
var onAction: (ShellAction) -> Unit = {} var onAction: (ShellAction) -> Unit = {}
var personCommit by mutableStateOf<org.justinvisser.wolfskin.design.PersonCommit?>(null)
val playback = org.justinvisser.wolfskin.design.PlaybackStart()
} }
/** The shell routes only through the existing NavigationManager and real settings pages. */ /** The shell routes only through the existing NavigationManager and real settings pages. */
@ -81,6 +94,7 @@ class WolfskinShellViewModel @Inject constructor(
val conductarr: ConductarrSession, val conductarr: ConductarrSession,
artwork: WolfskinArtwork, artwork: WolfskinArtwork,
val updates: org.justinvisser.wolfskin.tv.update.UpdateController, val updates: org.justinvisser.wolfskin.tv.update.UpdateController,
private val trailerReturn: TrailerReturnState,
) : ViewModel() { ) : ViewModel() {
init { artwork.configure(context) } init { artwork.configure(context) }
val backdropFlow: StateFlow<BackdropResult> = backdrop.backdropFlow val backdropFlow: StateFlow<BackdropResult> = backdrop.backdropFlow
@ -108,10 +122,20 @@ class WolfskinShellViewModel @Inject constructor(
session.serverId = server.id.toString() session.serverId = server.id.toString()
session.fixtureMode = ConductarrPreference.fixtures session.fixtureMode = ConductarrPreference.fixtures
val people = withContext(Dispatchers.IO) { readShellPeople(context, listOf(user)) } val people = withContext(Dispatchers.IO) { readShellPeople(context, listOf(user)) }
session.state = ShellState(people, signedInPerson) { session.onAction(it) } val continueTrailer = trailerReturn.hasConfirmedRestore() && trailerReturn.consumeRestoredSession(repository.current.value?.takeIf {
it.server.id == server.id && it.user.id == user.id
})
session.state = ShellState(people, signedInPerson) { session.onAction(it) }.apply {
if (continueTrailer) {
pickerVisible = false
pickerLaunch = false
}
}
val saved = withContext(Dispatchers.IO) { repository.serverDao.getServer(server.id)?.users.orEmpty() } val saved = withContext(Dispatchers.IO) { repository.serverDao.getServer(server.id)?.users.orEmpty() }
val savedPeople = withContext(Dispatchers.IO) { readShellPeople(context, saved.ifEmpty { listOf(user) }) } val savedPeople = withContext(Dispatchers.IO) { readShellPeople(context, saved.ifEmpty { listOf(user) }) }
session.state?.updatePeople(savedPeople) session.state?.updatePeople(savedPeople)
} else {
trailerReturn.consumeRestoredSession(null)
} }
identityJob = viewModelScope.launch { identityJob = viewModelScope.launch {
if (!ConductarrPreference.fixtures) loadHousehold(user) if (!ConductarrPreference.fixtures) loadHousehold(user)
@ -202,6 +226,7 @@ class WolfskinShellViewModel @Inject constructor(
} }
} }
ShellAction.Pop -> navigation.goBack() ShellAction.Pop -> navigation.goBack()
ShellAction.Exit -> navigation.requestExit()
is ShellAction.HandOff -> navigation.navigateTo(handOffDestination(action.group)) is ShellAction.HandOff -> navigation.navigateTo(handOffDestination(action.group))
is ShellAction.Person -> selectPerson(action.person, server) is ShellAction.Person -> selectPerson(action.person, server)
ShellAction.RetryPeople -> viewModelScope.launch { retryConductarr(server, user) } ShellAction.RetryPeople -> viewModelScope.launch { retryConductarr(server, user) }
@ -214,28 +239,48 @@ class WolfskinShellViewModel @Inject constructor(
} }
private fun selectPerson(person: ShellPerson, server: JellyfinServer) { private fun selectPerson(person: ShellPerson, server: JellyfinServer) {
val commit = session.state?.takeIf { it.peopleStatus == PeopleStatus.Ready }?.let {
org.justinvisser.wolfskin.design.PersonCommit(it.people.toList(), it.people.indexOfFirst { candidate -> candidate.key == person.key }, person.key)
}
session.personCommit = commit
session.playback.fail()
navigation.replace(Destination.Home()) navigation.replace(Destination.Home())
if (person.key == signedInPerson) return if (person.key == signedInPerson) {
commit?.switchEnded = true
return
}
identityJob?.cancel() identityJob?.cancel()
personSwitchJob?.cancel() personSwitchJob?.cancel()
signedInPerson = "" signedInPerson = ""
conductarr.close() conductarr.close()
libraries.audiences = null libraries.audiences = null
personSwitchJob = viewModelScope.launch { personSwitchJob = viewModelScope.launch {
personSwitchMutex.withLock { try {
val saved = withContext(Dispatchers.IO) { personSwitchMutex.withLock {
repository.serverDao.getServer(server.id)?.users?.firstOrNull { it.id.toString() == person.jellyfinUserId || it.name.equals(person.name, ignoreCase = true) } val saved = withContext(Dispatchers.IO) {
} repository.serverDao.getServer(server.id)?.users?.firstOrNull { it.id.toString() == person.jellyfinUserId || it.name.equals(person.name, ignoreCase = true) }
if (saved == null || saved.isProtected) return@launch }
try { if (saved == null || saved.isProtected) {
val current = repository.changeUser(server, saved) if (!ConductarrPreference.fixtures) {
coroutineContext.ensureActive() val open = { setupNavigation.navigateTo(SetupDestination.UserList(server)) }
setupNavigation.navigateTo(SetupDestination.AppContent(current)) if (commit == null) open() else commit.fallback = open
} catch (exception: Exception) { }
if (exception is kotlinx.coroutines.CancellationException) throw exception return@launch
coroutineContext.ensureActive() }
session.state?.apply { peopleStatus = PeopleStatus.Unavailable; pickerVisible = true } try {
val current = repository.changeUser(server, saved)
coroutineContext.ensureActive()
setupNavigation.navigateTo(SetupDestination.AppContent(current))
commit?.appReplacementPending = true
} catch (exception: Exception) {
if (exception is kotlinx.coroutines.CancellationException) throw exception
coroutineContext.ensureActive()
val open = { setupNavigation.navigateTo(SetupDestination.UserList(server)) }
if (commit == null) open() else commit.fallback = open
}
} }
} finally {
commit?.switchEnded = true
} }
} }
} }
@ -261,6 +306,10 @@ fun WolfskinApp(
viewModel: WolfskinShellViewModel = hiltViewModel(), viewModel: WolfskinShellViewModel = hiltViewModel(),
homeContent: @Composable (ShellState, JellyfinServer, JellyfinUser, Modifier) -> Unit = homeContent: @Composable (ShellState, JellyfinServer, JellyfinUser, Modifier) -> Unit =
{ shell, homeServer, homeUser, contentModifier -> HomeDestination(shell, homeServer, homeUser, contentModifier) }, { shell, homeServer, homeUser, contentModifier -> HomeDestination(shell, homeServer, homeUser, contentModifier) },
titleContent: @Composable (String, ShellState, UserPreferences) -> Unit =
{ route, shell, userPreferences -> TitleDestination(route, shell, userPreferences) },
fullScreenContent: @Composable (Destination, Modifier) -> Unit =
{ destination, contentModifier -> DestinationContent(destination, preferences, viewModel::clearBackdrop, contentModifier) },
) { ) {
val identity by viewModel.conductarr.identity.collectAsStateWithLifecycle() val identity by viewModel.conductarr.identity.collectAsStateWithLifecycle()
val installedVersion by viewModel.updates.installedVersion.collectAsStateWithLifecycle() val installedVersion by viewModel.updates.installedVersion.collectAsStateWithLifecycle()
@ -273,7 +322,9 @@ fun WolfskinApp(
} }
} }
val loading = remember(user.id) { val loading = remember(user.id) {
ShellState(listOf(ShellPerson(user.name.orEmpty().lowercase(), user.name.orEmpty(), user.name.orEmpty().take(1), false)), user.name.orEmpty().lowercase()).apply { peopleStatus = PeopleStatus.Loading } ShellState(listOf(ShellPerson(user.name.orEmpty().lowercase(), user.name.orEmpty(), user.name.orEmpty().take(1), false)), user.name.orEmpty().lowercase()) {
if (it == ShellAction.Exit) navigationManager.requestExit()
}.apply { peopleStatus = PeopleStatus.Loading }
} }
val state = viewModel.session.state ?: loading val state = viewModel.session.state ?: loading
val discover = rememberDiscoverState( val discover = rememberDiscoverState(
@ -287,11 +338,36 @@ fun WolfskinApp(
}, },
) )
RequestsRefresh(state, viewModel::refreshRequests) RequestsRefresh(state, viewModel::refreshRequests)
val onBack = rememberWolfskinBackHandler(state, navigationManager)
val session = viewModel.session
val commit = session.personCommit
LaunchedEffect(commit, commit?.timelineComplete, commit?.switchEnded, commit?.appReplacementPending, viewModel.signedInPerson) {
if (commit?.timelineComplete == true && (state.person.key == viewModel.signedInPerson || (commit.switchEnded && !commit.appReplacementPending))) {
session.personCommit = null
commit.fallback?.invoke()
}
}
var composedDestination by remember(server.id, user.id, navigationManager) { mutableStateOf<Destination?>(null) }
val playerEntryPending by remember(server.id, user.id, navigationManager) {
derivedStateOf {
val target = navigationManager.backStack.lastOrNull()
target is Destination.Playback && composedDestination != target
}
}
// Keep input blocked until the composition that removes the black cover has actually replaced it.
val playerCoverVisible = playerEntryPending
WolfskinTheme { WolfskinTheme {
CompositionLocalProvider(LocalShellState provides state, LocalDiscoverState provides discover, CompositionLocalProvider(LocalShellState provides state, LocalDiscoverState provides discover,
org.justinvisser.wolfskin.design.LocalHomePlaybackStart provides session.playback,
org.justinvisser.wolfskin.design.LocalScreenRatings provides if (identity?.signedIn == true && !ConductarrPreference.fixtures) remember(identity?.client) { ConductarrRatings(identity!!.client) } else null) { org.justinvisser.wolfskin.design.LocalScreenRatings provides if (identity?.signedIn == true && !ConductarrPreference.fixtures) remember(identity?.client) { ConductarrRatings(identity!!.client) } else null) {
Box(modifier) { Box(modifier.onPreviewKeyEvent { event ->
if (session.personCommit != null || session.playback.launch != null || playerEntryPending || playerCoverVisible) true else {
if (event.type == KeyEventType.KeyDown && org.justinvisser.wolfskin.design.readShellKey(event) == org.justinvisser.wolfskin.design.ShellKey.Back) session.playback.fail()
false
}
}) {
val current = navigationManager.backStack.last() val current = navigationManager.backStack.last()
LaunchedEffect(current, state.person.key) { session.playback.fail() }
if (!state.pickerVisible && resolveUpstreamDestination(current, WolfskinScreenPreference.enabled, viewModel.libraryDestinations) != null) { if (!state.pickerVisible && resolveUpstreamDestination(current, WolfskinScreenPreference.enabled, viewModel.libraryDestinations) != null) {
val backdrop by viewModel.backdropFlow.collectAsStateWithLifecycle() val backdrop by viewModel.backdropFlow.collectAsStateWithLifecycle()
Backdrop( Backdrop(
@ -302,45 +378,46 @@ fun WolfskinApp(
} }
NavDisplay( NavDisplay(
backStack = navigationManager.backStack, backStack = navigationManager.backStack,
onBack = { if (navigationManager.backStack.last().fullScreen) navigationManager.goBack() else state.key(org.justinvisser.wolfskin.design.ShellKey.Back) }, onBack = { if (session.personCommit == null && session.playback.launch == null && !playerEntryPending && !playerCoverVisible) { session.playback.fail(); onBack() } },
transitionSpec = { (EnterTransition.None togetherWith ExitTransition.None).using(null) },
popTransitionSpec = { (EnterTransition.None togetherWith ExitTransition.None).using(null) },
predictivePopTransitionSpec = { _ -> (EnterTransition.None togetherWith ExitTransition.None).using(null) },
entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator()), entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator()),
entryProvider = { destination -> entryProvider = { destination ->
NavEntry( NavEntry(
destination, destination,
contentKey = "${destination}_${server.id}_${user.id}", contentKey = "${destination}_${server.id}_${user.id}",
metadata = if (destination is Destination.Wolfskin && destination.route == "updates") {
org.justinvisser.wolfskin.tv.update.updatesRouteTransitions
} else emptyMap(),
) { ) {
// Cached entries must not retain the launch shell after sign-in completes. // Cached entries must not retain the launch shell after sign-in completes.
val state = LocalShellState.current val state = LocalShellState.current
val discover = LocalDiscoverState.current val discover = LocalDiscoverState.current
val route = when (destination) { val route = destination.toWolfskinRoute()
is Destination.Home -> "home"
is Destination.Wolfskin -> destination.route
is Destination.MediaItem -> if (destination.type == org.jellyfin.sdk.model.api.BaseItemKind.PERSON) personRoute(destination.itemId.toString()) else titleRoute(destination.itemId.toString())
else -> "pushed"
}
val isCurrent = navigationManager.backStack.lastOrNull() == destination val isCurrent = navigationManager.backStack.lastOrNull() == destination
if (isCurrent) SideEffect { composedDestination = destination }
LaunchedEffect(destination, state, isCurrent) { LaunchedEffect(destination, state, isCurrent) {
if (isCurrent && !destination.fullScreen) state.enterDestination(route) if (isCurrent && !destination.fullScreen) state.enterDestination(route)
} }
org.justinvisser.wolfskin.design.ScreenEntry(state, route, isCurrent) {
if (destination.fullScreen) { if (destination.fullScreen) {
DestinationContent(destination, preferences, viewModel::clearBackdrop, Modifier.fillMaxSize()) // Loading, error and external-player content may not paint their own background.
fullScreenContent(destination, Modifier.fillMaxSize()
.then(if (destination is Destination.Playback) Modifier.background(Color.Black) else Modifier))
} else { } else {
BackHandler(enabled = isCurrent) { state.key(org.justinvisser.wolfskin.design.ShellKey.Back) }
val upstream = resolveUpstreamDestination(destination, WolfskinScreenPreference.enabled, viewModel.libraryDestinations) val upstream = resolveUpstreamDestination(destination, WolfskinScreenPreference.enabled, viewModel.libraryDestinations)
if (upstream == null && (route.startsWith("title/") || route.startsWith("person/"))) { if (upstream == null && (route.startsWith("title/") || route.startsWith("person/"))) {
val jellyfinIdentity = destination is Destination.MediaItem || android.net.Uri.decode(route.substringAfter('/')).toUUIDOrNull() != null val jellyfinIdentity = destination is Destination.MediaItem || android.net.Uri.decode(route.substringAfter('/')).toUUIDOrNull() != null
if (jellyfinIdentity && state.person.key != viewModel.signedInPerson) { if (jellyfinIdentity && state.person.key != viewModel.signedInPerson) {
ShellSurface(state, handlesContentKeys = false) { ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", it) } ShellSurface(state, handlesContentKeys = false) {
} else TitleDestination(route, state, preferences) if (session.personCommit == null) ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", it)
}
} else titleContent(route, state, preferences)
} else { } else {
ShellSurface(state, handlesContentKeys = upstream == null, ShellSurface(state, handlesContentKeys = upstream == null,
settingsInstalledVersion = installedVersion.versionName, settingsInstalledVersion = installedVersion.versionName,
overlay = { if (route == "discover" || route.startsWith("discover-")) DiscoverOverlay(discover) }) { contentModifier -> overlay = { if (route == "discover" || route.startsWith("discover-")) DiscoverOverlay(discover) }) { contentModifier ->
when { when {
(upstream != null || destination is Destination.Home || route in LIBRARY_DESTINATIONS) && state.person.key != viewModel.signedInPerson -> ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", contentModifier) (upstream != null || destination is Destination.Home || route in LIBRARY_DESTINATIONS) && state.person.key != viewModel.signedInPerson ->
if (session.personCommit == null) ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", contentModifier)
upstream != null -> DestinationContent(upstream, preferences, viewModel::clearBackdrop, contentModifier) upstream != null -> DestinationContent(upstream, preferences, viewModel::clearBackdrop, contentModifier)
destination is Destination.Home -> homeContent(state, server, user, contentModifier) destination is Destination.Home -> homeContent(state, server, user, contentModifier)
route == "updates" -> org.justinvisser.wolfskin.tv.update.UpdatesScreen(state, viewModel.updates, route == "updates" -> org.justinvisser.wolfskin.tv.update.UpdatesScreen(state, viewModel.updates,
@ -354,9 +431,24 @@ fun WolfskinApp(
} }
} }
} }
}
} }
}, },
) )
org.justinvisser.wolfskin.design.PlaybackHandoffVeil(session.playback,
Modifier.pointerInput(session.playback.launch) {
awaitPointerEventScope { while (true) awaitPointerEvent().changes.forEach { it.consume() } }
})
if (playerCoverVisible) Box(Modifier.fillMaxSize().background(Color.Black)
.pointerInput(Unit) {
awaitPointerEventScope { while (true) awaitPointerEvent().changes.forEach { it.consume() } }
})
if (commit != null) org.justinvisser.wolfskin.design.PersonCommitVeil(commit)
BackHandler(enabled = session.playback.starting != null && session.playback.launch == null && !playerCoverVisible) {
session.playback.fail()
onBack()
}
BackHandler(enabled = commit != null || session.playback.launch != null || playerCoverVisible) { }
} }
} }
} }

View file

@ -0,0 +1,46 @@
package org.justinvisser.wolfskin.tv
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.findActivity
import org.justinvisser.wolfskin.design.ShellKey
import org.justinvisser.wolfskin.design.ShellState
/** Own root Back even when NavDisplay has no parent; child menus/dialogs retain first refusal. */
@Composable
internal fun rememberWolfskinBackHandler(shell: ShellState, navigation: NavigationManager): () -> Unit {
val activity = checkNotNull(LocalContext.current.findActivity()) { "Wolfskin root requires an Activity" }
LaunchedEffect(navigation, activity) {
navigation.exitRequests.collect { activity.finishAndRemoveTask() }
}
val onBack: () -> Unit = remember(shell, navigation) {
{ handleWolfskinBack(shell, navigation) }
}
BackHandler(onBack = onBack)
return onBack
}
/** Back reads navigation synchronously; an outgoing entry may outlive the composition that disabled it. */
internal fun handleWolfskinBack(shell: ShellState, navigation: NavigationManager) {
val destination = navigation.backStack.last()
if (destination.fullScreen) {
navigation.goBack()
return
}
val route = destination.toWolfskinRoute()
if (shell.route != route) shell.enterDestination(route)
shell.key(ShellKey.Back)
}
/** Navigation and Back use the same route identity for each rendered entry. */
internal fun Destination.toWolfskinRoute(): String = when (this) {
is Destination.Home -> "home"
is Destination.Wolfskin -> route
is Destination.MediaItem -> if (type == org.jellyfin.sdk.model.api.BaseItemKind.PERSON) personRoute(itemId.toString()) else titleRoute(itemId.toString())
else -> "pushed"
}

View file

@ -0,0 +1,22 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv
import android.content.Context
import com.github.damontecres.wholphin.data.CurrentUser
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.services.SetupNavigationManager
import org.jellyfin.sdk.api.client.exception.InvalidStatusException
/** Only an expired current login leaves saved content; outages and late failures keep it usable. */
fun recoverWolfskinSession(
context: Context,
exception: Exception,
expected: CurrentUser?,
repository: ServerRepository,
setup: SetupNavigationManager,
) {
if (exception !is InvalidStatusException || exception.status != 401 || expected == null || repository.current.value !== expected) return
WolfskinScreenPreference.initialize(context)
if (WolfskinScreenPreference.enabled) setup.navigateTo(SetupDestination.UserList(expected.server))
}

View file

@ -0,0 +1,187 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
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.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.input.KeyboardType
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.tv.material3.Button
import androidx.tv.material3.ButtonDefaults
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.ui.components.EditTextBox
import com.github.damontecres.wholphin.ui.setup.PinEntryDialog
import kotlinx.coroutines.launch
import org.justinvisser.wolfskin.design.ShellAction
import org.justinvisser.wolfskin.design.ShellSurface
import org.justinvisser.wolfskin.design.WolfskinTheme
import org.justinvisser.wolfskin.design.WolfskinTokens
import org.justinvisser.wolfskin.design.Wordmark
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S
import org.justinvisser.wolfskin.tv.WolfskinSetupViewModel.Stage
/** Intercept both stock setup destinations before either upstream selector can be composed. */
@Composable
fun WolfskinSetupContent(destination: SetupDestination, modifier: Modifier = Modifier, model: WolfskinSetupViewModel? = null): Boolean {
if (destination != SetupDestination.ServerList && destination !is SetupDestination.UserList) return false
val context = LocalContext.current
remember(context) {
WolfskinScreenPreference.initialize(context)
ConductarrPreference.initialize(context)
true
}
if (!WolfskinScreenPreference.enabled) return false
WolfskinSetupScreen(destination, modifier, model ?: hiltViewModel())
return true
}
/** Saved people remain local; network failures expose retry and real server authentication. */
@Composable
internal fun WolfskinSetupScreen(
destination: SetupDestination,
modifier: Modifier = Modifier,
model: WolfskinSetupViewModel = hiltViewModel(),
) {
val scope = rememberCoroutineScope()
val hint = (destination as? SetupDestination.UserList)?.server
LaunchedEffect(destination) { model.load(hint) }
val retrySetup: () -> Unit = { scope.launch { model.retry() } }
model.onAction = { action ->
when (action) {
is ShellAction.Person -> scope.launch { model.select(action.person) }
is ShellAction.Destination -> model.showSettings()
else -> Unit
}
}
BackHandler {
if (model.pinUser != null) model.dismissPin()
else model.returnToPicker()
}
WolfskinTheme {
val picker = model.picker
if (model.stage == Stage.Picker && picker != null) {
ShellSurface(picker, modifier.testTag("wolfskin-setup-picker"))
} else {
SetupPanel(model.stage, modifier) {
when (model.stage) {
Stage.Loading -> Text("Reading saved profiles…", color = C.muted, style = WolfskinTokens.Type.body)
Stage.Unconfigured -> ServerForm(model)
Stage.Login -> LoginForm(model)
Stage.Settings -> {
SetupButton("Sign in to another profile", !model.busy, initialFocus = true) { model.showLogin(null) }
SetupButton("Back to profiles", !model.busy, onClick = model::returnToPicker)
}
Stage.Unavailable -> {
Text(model.message.orEmpty(), color = C.muted, style = WolfskinTokens.Type.body)
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
SetupButton("Try again", !model.busy, initialFocus = true, onClick = retrySetup)
if (model.server != null) SetupButton("Sign in", !model.busy) { model.showLogin(null) }
}
}
Stage.Picker -> Unit
}
}
}
model.pinUser?.let { user ->
PinEntryDialog(
onDismissRequest = model::dismissPin,
onClickServerAuth = { model.showLogin(user) },
onTextChange = { value -> scope.launch { model.enterPin(value) } },
)
}
}
}
@Composable
private fun SetupPanel(stage: Stage, modifier: Modifier, content: @Composable () -> Unit) {
Box(modifier.fillMaxSize().background(C.bg).testTag("wolfskin-setup-${stage.name.lowercase()}")) {
Wordmark(Modifier.offset(L.safeSide, L.safeTopPicker))
Column(Modifier.align(Alignment.Center).width(S.pickerErrorPanelWidth),
verticalArrangement = Arrangement.spacedBy(S.panelGap)) {
Text(when (stage) {
Stage.Unconfigured -> "Connect your household server"
Stage.Login -> "Sign in to your profile"
Stage.Settings -> "Settings"
else -> "Who’s watching?"
}, color = C.fg, style = WolfskinTokens.Type.screenTitle)
content()
}
}
}
@Composable
private fun ServerForm(model: WolfskinSetupViewModel) {
var url by remember { mutableStateOf("") }
val scope = rememberCoroutineScope()
val focus = remember { FocusRequester() }
LaunchedEffect(Unit) { focus.requestFocus() }
Text("Enter your Jellyfin server URL once. Wolfskin will remember it on this TV.", color = C.muted, style = WolfskinTokens.Type.body)
EditTextBox(value = url, onValueChange = { url = it }, modifier = Modifier.fillMaxWidth().focusRequester(focus).testTag("household-server-url"),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri))
model.message?.let { Text(it, color = C.warn, style = WolfskinTokens.Type.body) }
SetupButton(if (model.busy) "Connecting…" else "Try again", !model.busy && url.isNotBlank()) {
scope.launch { model.connect(url) }
}
}
@Composable
private fun LoginForm(model: WolfskinSetupViewModel) {
var username by remember(model.loginUser) { mutableStateOf(model.loginUser?.name.orEmpty()) }
var password by remember(model.loginUser) { mutableStateOf("") }
val scope = rememberCoroutineScope()
val focus = remember { FocusRequester() }
val initialUsername = remember(model.loginUser) { username.isBlank() }
LaunchedEffect(model.loginUser) { focus.requestFocus() }
Text(model.server?.name ?: model.server?.url.orEmpty(), color = C.muted, style = WolfskinTokens.Type.body)
Text("Username", color = C.fg, style = WolfskinTokens.Type.body)
EditTextBox(value = username, onValueChange = { username = it }, modifier = Modifier.fillMaxWidth()
.then(if (initialUsername) Modifier.focusRequester(focus) else Modifier).testTag("profile-username"))
Text("Password", color = C.fg, style = WolfskinTokens.Type.body)
EditTextBox(value = password, onValueChange = { password = it }, modifier = Modifier.fillMaxWidth()
.then(if (!initialUsername) Modifier.focusRequester(focus) else Modifier).testTag("profile-password"),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password))
model.message?.let { Text(it, color = C.warn, style = WolfskinTokens.Type.body) }
SetupButton(if (model.busy) "Signing in…" else "Sign in", !model.busy && username.isNotBlank()) {
val submitted = password
password = ""
scope.launch { model.login(username, submitted) }
}
}
@Composable
private fun SetupButton(label: String, enabled: Boolean, initialFocus: Boolean = false, onClick: () -> Unit) {
val focus = remember { FocusRequester() }
LaunchedEffect(Unit) { if (initialFocus && enabled) focus.requestFocus() }
Button(onClick = onClick, enabled = enabled, modifier = Modifier.focusRequester(focus),
shape = ButtonDefaults.shape(shape = RoundedCornerShape(WolfskinTokens.Radius.button)),
colors = ButtonDefaults.colors(containerColor = C.buttonSecondary, contentColor = C.fg,
focusedContainerColor = C.fg, focusedContentColor = C.onFocus)) {
Text(label, style = WolfskinTokens.Type.button)
}
}

View file

@ -0,0 +1,208 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import com.github.damontecres.wholphin.data.RestoredSession
import com.github.damontecres.wholphin.data.ServerRepository
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.services.SetupDestination
import com.github.damontecres.wholphin.services.SetupNavigationManager
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.exception.InvalidStatusException
import org.jellyfin.sdk.api.client.extensions.authenticateUserByName
import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.discovery.RecommendedServerInfoScore
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import org.justinvisser.wolfskin.design.ShellAction
import org.justinvisser.wolfskin.design.ShellPerson
import org.justinvisser.wolfskin.design.ShellState
/** Setup never clears the remembered household endpoint or login merely to show the picker. */
@HiltViewModel
class WolfskinSetupViewModel @Inject constructor(
private val repository: ServerRepository,
private val navigation: NavigationManager,
private val setup: SetupNavigationManager,
private val session: WolfskinSession,
private val conductarr: ConductarrSession,
) : ViewModel() {
internal enum class Stage { Loading, Unconfigured, Picker, Login, Settings, Unavailable }
internal var stage by mutableStateOf(Stage.Loading)
private set
internal var server by mutableStateOf<JellyfinServer?>(null)
private set
internal var picker by mutableStateOf<ShellState?>(null)
private set
internal var message by mutableStateOf<String?>(null)
private set
internal var busy by mutableStateOf(false)
private set
internal var pinUser by mutableStateOf<JellyfinUser?>(null)
private set
internal var loginUser by mutableStateOf<JellyfinUser?>(null)
private set
private var users: List<JellyfinUser> = emptyList()
internal var onAction: (ShellAction) -> Unit = {}
/** Recover Room's saved household server even if the old selector erased its preference. */
internal suspend fun load(hint: JellyfinServer?) = perform {
conductarr.close()
server = hint ?: repository.current.value?.server ?: when (val recent = repository.getMostRecentServer()) {
is RestoredSession.ServerOnly -> recent.server
is RestoredSession.Success -> recent.currentUser.server
RestoredSession.None -> withContext(Dispatchers.IO) {
repository.serverDao.getServers().maxWithOrNull(compareBy { it.server.lastUsed })?.server
}
}
val household = server
if (household == null) {
stage = Stage.Unconfigured
return@perform
}
users = withContext(Dispatchers.IO) { repository.serverDao.getServer(household.id)?.users.orEmpty() }
if (users.isEmpty()) {
users = withContext(Dispatchers.IO) {
repository.jellyfin.createApi(household.url).userApi.getPublicUsers().content.map {
JellyfinUser(id = it.id, name = it.name, serverId = household.id, accessToken = null)
}
}
}
if (users.isEmpty()) {
showLogin(null)
} else {
val people = users.map { ShellPerson(it.name.orEmpty().lowercase(), it.name.orEmpty(), it.name.orEmpty().take(1).uppercase(), false, it.id.toString()) }
picker = ShellState(people, people.first().key) { onAction(it) }
stage = Stage.Picker
}
}
/** Retry the startup server/profile recovery, never the failed saved-token request. */
internal suspend fun retry() {
if (busy) return
stage = Stage.Loading
load(null)
}
/** Discovery and persistence use the same Jellyfin contract as upstream's add-server action. */
internal suspend fun connect(url: String) = perform {
val household = withContext(Dispatchers.IO) {
val candidate = repository.jellyfin.discovery.getRecommendedServers(url.trim())
.sortedBy { it.score }.firstOrNull { it.score != RecommendedServerInfoScore.BAD }
val info = candidate?.systemInfo?.getOrNull()
val id = info?.id?.toUUIDOrNull()
require(candidate != null && info != null && id != null && info.startupWizardCompleted == true) { "Could not connect to the household server. Check the URL and try again." }
JellyfinServer(id, info.serverName, candidate.address, info.version).also {
repository.addAndChangeServer(it)
}
}
server = household
setup.navigateTo(SetupDestination.UserList(household))
}
/** Protected saved users must authenticate before changeUser is allowed to run. */
internal suspend fun select(person: ShellPerson) {
picker?.pickerVisible = true
if (busy) return
val selected = users.firstOrNull { it.id.toString() == person.jellyfinUserId } ?: return
when {
selected.accessToken.isNullOrBlank() || selected.requireLogin -> showLogin(selected)
selected.hasPin -> pinUser = selected
else -> changeUser(selected)
}
}
internal fun showLogin(user: JellyfinUser?) {
pinUser = null
loginUser = user
message = null
stage = Stage.Login
}
internal fun dismissPin() { pinUser = null }
internal fun showSettings() {
picker?.pickerVisible = true
if (!busy) stage = Stage.Settings
}
internal fun returnToPicker() {
if (busy || picker == null) return
picker?.pickerVisible = true
stage = Stage.Picker
}
internal suspend fun enterPin(value: String) {
val selected = pinUser ?: return
if (value == selected.pin) {
pinUser = null
changeUser(selected)
}
}
private suspend fun changeUser(user: JellyfinUser) = perform {
try {
complete(repository.changeUser(requireNotNull(server), user))
} catch (exception: InvalidStatusException) {
if (exception.status != 401) throw exception
showLogin(user)
message = "Your saved sign-in expired. Sign in again to continue."
}
}
/** A password is sent only to the saved household server and never retained by this model. */
internal suspend fun login(username: String, password: String) = perform {
val household = requireNotNull(server)
val current = withContext(Dispatchers.IO) {
val result = repository.jellyfin.createApi(household.url).userApi
.authenticateUserByName(username = username, password = password).content
repository.changeUser(household.url, result, loginUser)
}
complete(current)
}
private fun complete(current: com.github.damontecres.wholphin.data.CurrentUser) {
if (ConductarrPreference.fixtures) {
session.state = null
} else {
val saved = users.filterNot { it.id == current.user.id } + current.user
val people = saved.map { ShellPerson(it.name.orEmpty().lowercase(), it.name.orEmpty(), it.name.orEmpty().take(1).uppercase(), false, it.id.toString()) }
session.state = ShellState(people, current.user.name.orEmpty().lowercase()) { session.onAction(it) }.apply {
pickerVisible = false
pickerLaunch = false
}
}
session.serverId = current.server.id.toString()
session.fixtureMode = ConductarrPreference.fixtures
navigation.reloadHome()
setup.navigateTo(SetupDestination.AppContent(current))
}
private suspend fun perform(action: suspend () -> Unit) {
if (busy) return
busy = true
message = null
try {
action()
} catch (exception: Exception) {
if (exception is CancellationException) throw exception
message = if (exception is InvalidStatusException && exception.status == 401) "Invalid username or password. Try again."
else if (exception is InvalidStatusException && exception.status == 403) "This profile is not authorized. Choose another profile or sign in again."
else if (server == null && stage == Stage.Unconfigured) "Could not connect to the household server. Check the URL and try again."
else if (server == null) "Could not read the saved household server. Try again."
else "The household server didn’t answer. Try again or sign in with your server credentials."
if (stage != Stage.Login && stage != Stage.Unconfigured) stage = Stage.Unavailable
} finally {
busy = false
}
}
}

View file

@ -47,6 +47,7 @@ class HomeLibraryCache @Inject constructor(@param:ApplicationContext context: Co
put("seriesTitle", title.seriesTitle); put("season", title.season); put("episode", title.episode) put("seriesTitle", title.seriesTitle); put("season", title.season); put("episode", title.episode)
put("resume", title.resumeSeconds); put("played", title.played); put("addedAt", title.addedAt) put("resume", title.resumeSeconds); put("played", title.played); put("addedAt", title.addedAt)
put("genres", JSONArray(title.genres)); put("tmdb", title.tmdbId) put("genres", JSONArray(title.genres)); put("tmdb", title.tmdbId)
put("seriesId", title.seriesId); put("lastPlayedAt", title.lastPlayedAt)
}) } }) }
} }
@ -59,6 +60,7 @@ class HomeLibraryCache @Inject constructor(@param:ApplicationContext context: Co
item.getString("id"), item.getString("title"), item.getString("type"), number("year"), number("runtime"), number("seasons"), item.getString("overview"), item.getString("id"), item.getString("title"), item.getString("type"), number("year"), number("runtime"), number("seasons"), item.getString("overview"),
string("poster"), string("backdrop"), item.getBoolean("posterOnly"), string("seriesTitle"), number("season"), number("episode"), item.getDouble("resume"), item.getBoolean("played"), item.getString("addedAt"), string("poster"), string("backdrop"), item.getBoolean("posterOnly"), string("seriesTitle"), number("season"), number("episode"), item.getDouble("resume"), item.getBoolean("played"), item.getString("addedAt"),
List(genres.length()) { genres.getString(it) }, held = true, tmdbId = number("tmdb"), List(genres.length()) { genres.getString(it) }, held = true, tmdbId = number("tmdb"),
seriesId = string("seriesId"), lastPlayedAt = if (item.isNull("lastPlayedAt")) null else item.getLong("lastPlayedAt"),
) )
} }

View file

@ -3,7 +3,12 @@ package org.justinvisser.wolfskin.tv.read
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.ImageUrlService
import java.util.UUID import java.util.UUID
import java.time.ZoneOffset
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.itemsApi import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.api.client.extensions.tvShowsApi import org.jellyfin.sdk.api.client.extensions.tvShowsApi
@ -27,18 +32,39 @@ class HomeReadFacade @Inject constructor(private val api: ApiClient, private val
suspend fun readWatch(userId: UUID): HomeLibrary { suspend fun readWatch(userId: UUID): HomeLibrary {
val resumeItems = api.itemsApi.getResumeItems(userId = userId, fields = FIELDS, mediaTypes = listOf(MediaType.VIDEO), limit = 20).content.items val resumeItems = api.itemsApi.getResumeItems(userId = userId, fields = FIELDS, mediaTypes = listOf(MediaType.VIDEO), limit = 20).content.items
val nextItems = api.tvShowsApi.getNextUp(userId = userId, fields = FIELDS, limit = 20, enableUserData = true).content.items val nextItems = api.tvShowsApi.getNextUp(userId = userId, fields = FIELDS, limit = 20, enableUserData = true).content.items
val series = (resumeItems + nextItems).mapNotNull { it.seriesId }.distinct().associateWith { id -> val seriesIds = buildSet {
try { api.userLibraryApi.getItem(userId = userId, itemId = id).content.providerIds?.get("Tmdb")?.toIntOrNull() } resumeItems.forEach { it.seriesId?.let(::add) }
catch (exception: Exception) { nextItems.forEach { it.seriesId?.let(::add) }
if (exception is kotlinx.coroutines.CancellationException) throw exception }
null val series = coroutineScope {
} val limiter = Semaphore(WATCH_READ_CONCURRENCY)
seriesIds.associateWith { id -> async { limiter.withPermit { readSeriesWatch(userId, id) } } }
.mapValues { it.value.await() }
} }
val resume = resumeItems.map { title(it, series) } val resume = resumeItems.map { title(it, series) }
val next = nextItems.map { title(it, series) } val next = nextItems.map { title(it, series) }
return HomeLibrary(resume = resume, nextUp = next) return HomeLibrary(resume = resume, nextUp = next)
} }
private suspend fun readSeriesWatch(userId: UUID, id: UUID): SeriesWatch {
val tmdb = try { api.userLibraryApi.getItem(userId = userId, itemId = id).content.providerIds?.get("Tmdb")?.toIntOrNull() }
catch (exception: Exception) {
if (exception is kotlinx.coroutines.CancellationException) throw exception
null
}
// Next-up episodes are unwatched; date the series by its latest actually watched episode.
val playedAt = try {
api.itemsApi.getItems(userId = userId, parentId = id, recursive = true,
includeItemTypes = listOf(BaseItemKind.EPISODE), isPlayed = true, enableUserData = true,
sortBy = listOf(ItemSortBy.DATE_PLAYED), sortOrder = listOf(SortOrder.DESCENDING), limit = 1)
.content.items.firstOrNull()?.userData?.lastPlayedDate?.toInstant(ZoneOffset.UTC)?.toEpochMilli()
} catch (exception: Exception) {
if (exception is kotlinx.coroutines.CancellationException) throw exception
null
}
return SeriesWatch(tmdb, playedAt)
}
/** Library failures do not discard an already successful watch read. */ /** Library failures do not discard an already successful watch read. */
suspend fun readLibrary(userId: UUID): HomeLibrary { suspend fun readLibrary(userId: UUID): HomeLibrary {
val libraries = api.userViewsApi.getUserViews(userId = userId).content.items val libraries = api.userViewsApi.getUserViews(userId = userId).content.items
@ -66,7 +92,7 @@ class HomeReadFacade @Inject constructor(private val api: ApiClient, private val
return readPlayback(api, userId, id) return readPlayback(api, userId, id)
} }
private fun title(item: BaseItemDto, series: Map<UUID, Int?> = emptyMap()): HomeTitle { private fun title(item: BaseItemDto, series: Map<UUID, SeriesWatch> = emptyMap()): HomeTitle {
val wrapped = BaseItem(item) val wrapped = BaseItem(item)
return HomeTitle( return HomeTitle(
id = item.id.toString(), title = item.name.orEmpty(), type = when (item.type) { BaseItemKind.EPISODE -> "episode"; BaseItemKind.SERIES -> "tv"; else -> "movie" }, id = item.id.toString(), title = item.name.orEmpty(), type = when (item.type) { BaseItemKind.EPISODE -> "episode"; BaseItemKind.SERIES -> "tv"; else -> "movie" },
@ -76,12 +102,18 @@ class HomeReadFacade @Inject constructor(private val api: ApiClient, private val
seriesTitle = item.seriesName, season = item.parentIndexNumber, episode = item.indexNumber, seriesTitle = item.seriesName, season = item.parentIndexNumber, episode = item.indexNumber,
resumeSeconds = (item.userData?.playbackPositionTicks ?: 0) / TICKS_PER_SECOND.toDouble(), played = item.userData?.played == true, resumeSeconds = (item.userData?.playbackPositionTicks ?: 0) / TICKS_PER_SECOND.toDouble(), played = item.userData?.played == true,
addedAt = item.dateCreated?.toString().orEmpty(), genres = item.genres.orEmpty(), held = true, addedAt = item.dateCreated?.toString().orEmpty(), genres = item.genres.orEmpty(), held = true,
tmdbId = if (item.type == BaseItemKind.EPISODE) series[item.seriesId] else item.providerIds?.get("Tmdb")?.toIntOrNull(), tmdbId = if (item.type == BaseItemKind.EPISODE) series[item.seriesId]?.tmdbId else item.providerIds?.get("Tmdb")?.toIntOrNull(),
seriesId = item.seriesId?.toString(),
lastPlayedAt = maxOf(item.userData?.lastPlayedDate?.toInstant(ZoneOffset.UTC)?.toEpochMilli() ?: Long.MIN_VALUE,
series[item.seriesId]?.lastPlayedAt ?: Long.MIN_VALUE).takeUnless { it == Long.MIN_VALUE },
) )
} }
private data class SeriesWatch(val tmdbId: Int?, val lastPlayedAt: Long?)
companion object { companion object {
private const val TICKS_PER_SECOND = 10_000_000L private const val TICKS_PER_SECOND = 10_000_000L
private const val WATCH_READ_CONCURRENCY = 3
private val FIELDS = listOf(ItemFields.PROVIDER_IDS, ItemFields.OVERVIEW, ItemFields.DATE_CREATED, ItemFields.GENRES, ItemFields.CHILD_COUNT) private val FIELDS = listOf(ItemFields.PROVIDER_IDS, ItemFields.OVERVIEW, ItemFields.DATE_CREATED, ItemFields.GENRES, ItemFields.CHILD_COUNT)
} }
} }

View file

@ -192,6 +192,9 @@ class TitleReadFacade @Inject constructor(
streams.filter { it.type == MediaStreamType.AUDIO }.map { TitleTrack(it.index, it.displayTitle ?: it.language ?: it.codec.orEmpty(), it.isDefault) }, streams.filter { it.type == MediaStreamType.AUDIO }.map { TitleTrack(it.index, it.displayTitle ?: it.language ?: it.codec.orEmpty(), it.isDefault) },
streams.filter { it.type == MediaStreamType.SUBTITLE }.map { TitleTrack(it.index, it.displayTitle ?: it.language ?: it.codec.orEmpty(), it.isDefault) }) streams.filter { it.type == MediaStreamType.SUBTITLE }.map { TitleTrack(it.index, it.displayTitle ?: it.language ?: it.codec.orEmpty(), it.isDefault) })
} }
// Rich playback/watch rows are prefixed in storage, not in the season's display order.
val seasonEpisodes = rows.filterTo(ArrayList()) { it.type == BaseItemKind.EPISODE && it.parentIndexNumber == selectedSeason }
seasonEpisodes.sortBy { it.indexNumber }
val pageItem = mapItem(item).copy(seasons = seasonNumbers.size) 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) }, 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, rows.first { it.id.toString() == watchId }.let { mapItem(it, pageItem.backdrop) }, versions, chosen?.source?.id, chosen?.audioStream?.stream?.index,
@ -200,7 +203,7 @@ class TitleReadFacade @Inject constructor(
cast = item.people.orEmpty().filter { it.type.toString().equals("Actor", true) }.map { person -> cast = item.people.orEmpty().filter { it.type.toString().equals("Actor", true) }.map { person ->
TitlePerson(person.id.toString(), person.name.orEmpty(), person.role.orEmpty(), person.primaryImageTag?.let { images.getItemImageUrl(person.id, ImageType.PRIMARY, maxWidth = 200, maxHeight = 200, tag = it) }) TitlePerson(person.id.toString(), person.name.orEmpty(), person.role.orEmpty(), person.primaryImageTag?.let { images.getItemImageUrl(person.id, ImageType.PRIMARY, maxWidth = 200, maxHeight = 200, tag = it) })
}, },
episodes = rows.filter { it.type == BaseItemKind.EPISODE && it.parentIndexNumber == selectedSeason }.map { mapItem(it, pageItem.backdrop) }, episodes = seasonEpisodes.map { mapItem(it, pageItem.backdrop) },
connection = TitleConnection.SignedOut, seasonNumbers = seasonNumbers, selectedSeason = selectedSeason) connection = TitleConnection.SignedOut, seasonNumbers = seasonNumbers, selectedSeason = selectedSeason)
} }

View file

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

View file

@ -4,7 +4,19 @@
## Who’s watching ## 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. 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 from any avatar reaches the gear; Up returns to its origin, including after visiting Settings. Left/Right avatar traversal is unchanged.
Source — Justin (2026-11-07): “the settings icon moved, but pressing down only works from the last profile icon. I want it to always move there when pressing down in that screen”.
Saved profiles retain their approved household colours before Conductarr answers: Justin **#62c4ff**, Mindel **#ff9d72**, Shared **#9be38f**. Setup uses the same lowercase-name presentation key as the signed-in picker; the separate Jellyfin user ID remains the authentication identity. `WolfskinSetupNavigationTest.coldStartSavedPeopleKeepTheirOwnColorsWithoutConductarr` exercises the real cold-start setup boundary and checks the native avatar-fill pixels without a Conductarr session.
Root content Back moves to the nav; the next Back **finishes and removes Wolfskin’s Android task**, returning to the launcher without visiting an old Home/setup interface. This applies to Home, Discover, Movies, Shows and Anime. Cold-launch picker Back exits; a reopened picker first dismisses to its opener’s nav. Pushed title/person/settings/Updates pages pop their parent stack, and menus/sheets/unsaved-settings prompts get first refusal. A singleton full-screen page exits rather than becoming a dead end.
Native Android Back (`KEYCODE_BACK`) belongs exclusively to the activity’s system dispatcher, including Navigation 3 predictive completion; Compose key previews must not also navigate on its raw key-down. Escape remains the existing local keyboard Back shortcut. Each completed system Back has one consumer. A retained title/person callback checks the live navigation entry at invocation and hands Back to the incoming entry if its own entry has already popped; it does not depend on an enabled flag catching up with the exit animation. Root handling synchronizes the shell route to the current entry before applying content → nav → task exit. Back ownership is exercised on both SDK 34 (Android 14) and SDK 36.
Source — Justin (2026-11-07): “pressing back enough times should close wolfskin, currently there is no way to go back to projectivity apart from weird methods via the old ui”.
## Focus visibility ## Focus visibility
@ -14,6 +26,23 @@ The focused card and its ring remain fully inside the viewport, with at least th
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. 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.
## Home continuation and navigation — round 2 amendment
Justin asked for one **Continue watching** lane merging in-progress films/episodes and next-up episodes. **Implementation choice:** reuse the existing Next up landscape-tile geometry for the merged lane, rather than the old Resume/wide geometry. Order by most recent actual watch activity and show a series once: its resumable episode takes precedence over its next-up episode. Unknown activity dates retain source order after known dates. The non-rotating hero keeps its resume → next-up → newest → pick precedence and is not duplicated in the lane.
The lane retains the `cw` identity. Legacy local, server, web and plugin Home settings merge saved Resume/Next up rows at the first watch row's position and preserve that row's view options. Existing serialized types remain readable; new settings offer only Continue watching.
Every navigation entry owns its own viewport even while an outgoing page and incoming page coexist during push/Back. Only the active entry may acquire Android focus or register the shell's logical key controller. An outgoing title/person page must never rebind to Home's `LazyListState` when the shared route changes. Down skips non-focusable/pending rows, reaches the final real row, and scrolls it into view after Back and refresh.
An unvisited Home row starts at its leftmost card rather than inheriting horizontal alignment from the previous row. Visited rows retain their remembered column and first-visible position across row changes, title Back and root-tab leave/return. Home does not adopt Discover’s tab-leave memory reset; Library keeps standard grid navigation. Outgoing title/person entries cannot intercept Android Back during their exit animation.
Landscape-card bottom gradients extend through the reserved state-line area, ending at the solid bottom backing with no exposed artwork band, as Justin requested. **Implementation choice for the amount:** title-gradient top padding is **36 px / 18 dp**, down from 54 px / 27 dp. The existing approved 92% bottom opacity is unchanged. Captions and state lines retain their existing geometry.
Home hero actions retain the approved primary + secondary Details layout: [st-007, Mindel’s Resume hero](https://pages.justinvisser.org/wolfskin/tv-spec/img/st-007-mindel-resume-in-the-hero.jpg) and [st-006, Justin’s Play-next hero](https://pages.justinvisser.org/wolfskin/tv-spec/img/st-006-justin-no-resume-hero-falls-back-to-next-up.jpg). Cached Home can paint before fresh watch data verifies playback. During that interval, measure and reserve the real primary button’s footprint without placing, drawing or enabling it; keep Details in its final secondary position and available for browsing. Playback readiness reveals the primary with the existing 200 ms late-line fade, never a lateral layout shift. The copy layer and button/spacing tokens do not change.
Source — Justin (2026-11-07): “the resume button for the home hero item only appears after a second or two, it appearing causes the details button to shift places, this needs to be fixed as well”.
## Episode artwork and dimensions ## 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. 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.
@ -54,6 +83,8 @@ Read-only source measurement: Desperate Housewives has a 2732×1536 Backdrop. Wi
Every Discover lane, including Movies for you, Series for you, service entries, service-specific lanes and Hidden picks, uses a **104 px heading band**: heading line plus **60 px heading-to-card gap**, up from 40 px. This is a consistent Level 2 extension, not a Series-only exception. Poster shelves become 652 px high and service-entry shelves 316 px. Captions and frame scroll offsets use the same heading band. Home headings remain unchanged. 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.
Round-2 Discover navigation amendment: Up/Down into an untouched lane starts at its leftmost item, not the previous lane's nearest column. A moved lane restores its own selected item and horizontal track for this visit. Pushed titles and service/collection pages preserve that visit; selecting another root tab or rebuilding Discover clears its lane positions. Root vertical position, filters and hero selection retain their existing shell policy. Home's nearest-centre shelves and Library's column-preserving continuous grids are unchanged pending a separate behavior decision.
## Motion and loading — revision 6 ## Motion and loading — revision 6
Home heroes and shelves, Library rows, Discover hero/lanes, title headers, episodes and cast, and person headers/filmography use shaped placeholders without loading sentences. The placeholder fill is `surface2` (`#1b1b25`). A complete shelf shares **one** alpha layer: **0.55 → 1 → 0.55 over 1400 ms**, CSS ease-in-out (`.42,0,.58,1`), 700 ms each way. All loading shelves under the screen's theme share one clock, so late arrivals join the current phase. The clock stops when the last loading shelf leaves. No per-card clocks, staggered pulse phases, shimmer, moving gradient, or animated blur. Home heroes and shelves, Library rows, Discover hero/lanes, title headers, episodes and cast, and person headers/filmography use shaped placeholders without loading sentences. The placeholder fill is `surface2` (`#1b1b25`). A complete shelf shares **one** alpha layer: **0.55 → 1 → 0.55 over 1400 ms**, CSS ease-in-out (`.42,0,.58,1`), 700 ms each way. All loading shelves under the screen's theme share one clock, so late arrivals join the current phase. The clock stops when the last loading shelf leaves. No per-card clocks, staggered pulse phases, shimmer, moving gradient, or animated blur.
@ -64,17 +95,20 @@ Home heroes and shelves, Library rows, Discover hero/lanes, title headers, episo
| Poster caption in / out | 60 ms delay + 180 ms ease-out / 100 ms ease-out | 10 px rise on entry only; exit holds its position, including when entry was interrupted | | Poster caption in / out | 60 ms delay + 180 ms ease-out / 100 ms ease-out | 10 px rise on entry only; exit holds its position, including when entry was interrupted |
| Network artwork only | 160 ms, Coil painter alpha | None; memory, disk and local hits are instant | | Network artwork only | 160 ms, Coil painter alpha | None; memory, disk and local hits are instant |
| Row/tab content reveal | 180 ms, ease-out | One layer per row, 24 px rise | | Row/tab content reveal | 180 ms, ease-out | One layer per row, 24 px rise |
| Initial hero/title copy | 180 ms, ease-out | 16 px rise | | Initial hero/title copy | 180 ms, ease-out | 16 px rise (`copyEntryRise`); saved hero entry state suppresses replay on remount, not first appearance |
| Hero backdrop hand-off | 320 ms, ease-in-out (`.65,0,.35,1`) | Alpha only; retain previous art until incoming art or its final fallback is ready | | Hero backdrop hand-off | 320 ms, ease-in-out (`.65,0,.35,1`) | Alpha only; retain previous art until incoming art or its final fallback is ready |
| Hero copy hand-off | Old copy out 120 ms ease (`.25,.1,.25,1`); new copy starts at 140 ms and enters over 200 ms ease-out | 14 px rise on entry | | Hero copy hand-off | Outgoing 120 ms ease (`.25,.1,.25,1`); incoming starts after 140 ms and enters over 200 ms ease-out, gated on artwork or final fallback readiness | One copy slot: no overlapping text; 14 px incoming rise, outgoing translation held where entry stopped |
| Late ratings, genres, overview and readiness | 200 ms, ease | Alpha only, inside existing measured slots; rating plates keep their existing plate fade rather than adding a nested fade | | Late ratings, genres, overview and readiness | 200 ms, ease | Alpha only, inside existing measured slots; rating plates keep their existing plate fade rather than adding a nested fade |
| Horizontal / vertical focus-follow scroll | 160 ms ease / 180 ms ease-out | Existing scroll geometry | | Horizontal / vertical focus-follow scroll | 160 ms ease / 180 ms ease-out | Existing scroll geometry |
| Discover pager | 180 ms, ease-out, retargeting current width and opacity | Buttons and pager remain outside the copy transition; reduced motion snaps width and caps opacity fades at 80 ms |
Row reveals are grouped rather than the browser prototype's per-card staging: the armv7 frame budget permits one animated layer per shelf, not one per card. Tabs have no full-screen transition layer. The upper Home hero blend is bounded to **1920×820 px**, Discover to **1920×740 px**; the lower backdrop changes without an animated screen-sized layer. Hero copy is a separate bounded block. Image reveal uses Coil's painter, not a new full-screen compositing layer. Existing poster-only blur is static. Row reveals are grouped rather than the browser prototype's per-card staging: the armv7 frame budget permits one animated layer per shelf, not one per card. Tabs have no full-screen transition layer. The upper Home hero blend is bounded to **1920×820 px**, Discover to **1920×740 px**; the entire painted backdrop stays static while incoming artwork loads, and the lower backdrop changes only when artwork or its final fallback is ready, without an animated screen-sized layer. Hero copy is a separate bounded block with stationary action controls. Ordinary image reveal uses Coil's painter; hero-controlled requests bypass that independent fade so only the token-backed hero blend owns the transition. Existing poster-only blur is static.
The title page's scroll dim is a draw-only colour overlay, not an alpha-animated full-screen layer. Discover scrolls its backdrop through placement offsets rather than a full-screen translation layer. Initial loading animated layers are Home **2**, Library **up to 3** lazy rows, Discover **2**, title header **1**, person **2**; pending title episode/cast shelves add **1 each**, and each pending Discover lane owns **1**. Deep Home loading is bounded to **4** visible shelf layers. These are animated layers, not independent pulse clocks or every static display-list node: one shared pulse clock drives the loading screen without adding a layer. A hero hand-off adds at most **2** bounded owners (outgoing art and copy). Focus changes can animate **2** card transforms, **1** caption and at most **2** rating plates; artwork fades are painter draws without additional compositing layers. Late title metadata adds at most **4** bounded alpha layers: title ratings, play-target ratings, genres and overview. No animated owner is full-screen. The title page's scroll dim is a draw-only colour overlay, not an alpha-animated full-screen layer. Discover scrolls its backdrop through placement offsets rather than a full-screen translation layer. Initial loading animated layers are Home **2**, Library **up to 3** lazy rows, Discover **2**, title header **1**, person **2**; pending title episode/cast shelves add **1 each**, and each pending Discover lane owns **1**. Deep Home loading is bounded to **4** visible shelf layers. These are animated layers, not independent pulse clocks or every static display-list node: one shared pulse clock drives the loading screen without adding a layer. An ordinary hero hand-off animates **2** bounded owners (outgoing art and one sequential copy block) above an opaque artwork base. Art interruptions retain the painted composite without resetting alpha; each outgoing art owner keeps its original 320 ms lifetime. Copy retargets its current alpha, fades out before replacing text, then uses the approved delay/entry tokens; never two copy layers are visible together. Focus changes can animate **2** card transforms, **1** caption and at most **2** rating plates; ordinary artwork fades are painter draws without additional compositing layers. Late title metadata adds at most **4** bounded alpha layers: title ratings, play-target ratings, genres and overview. No animated owner is full-screen.
The series readiness band reserves **56 px (28 dp)** from its first content paint, including when empty or when readiness later disappears. Ratings remain inside the fixed-height facts band. The series header's measured height therefore cannot change when late ratings/readiness arrive; overview, actions, season and episode anchors retain their coordinates. Skeletons have no click/hover focus targets; pending episode/cast rows remain absent from the key-navigation state until real cards exist. Initial loading keeps nav focus and Back still pops the title/person route. The approved title follow-up (2026-11-07) supersedes the fixed readiness reservation: an absent readiness line occupies **0 px**, while a present line occupies **56 px (28 dp)**. Late arrival/removal animates that height over the **200 ms late-line duration with token ease-out**; reduced motion snaps. Overview, actions and following rows move monotonically with that band; an already-pinned focused row stays pinned through same-remeasure scroll compensation. Ratings remain inside the fixed-height facts band and appear once: an explicit episode page uses its opened episode's own set, including the episode score, rather than the series-only set. Series and episode pages have no redundant Seasons shortcut; Down reaches the season chips. Skeletons have no click/hover focus targets; pending episode/cast rows remain absent from the key-navigation state until real cards exist. Initial title/person loading retains destination focus rather than focusing the root nav; Down cannot enter a skeleton, and Back still pops the route without opening the picker. An already-ready catalogue or held title stays Ready while a same-route, same-person refresh reads new data; the existing signed-in authentication guard remains authoritative, and loaded copy/rows do not remount or replay their entry motion.
Fixture foreground/watch refreshes likewise reread saved choices in place without losing Ready or remounting rows. Revoked catalogue authorization clears signed-in facts directly to the unavailable state without replaying the first-load skeleton. Catalogue-backed request/hide actions require a positive movie/series identity; incomplete metadata shows the Wolfskin recovery toast instead of throwing or acknowledging an unperformed hide. Debug title fixtures carry their existing TMDB identity, including the series root when viewing an episode.
Discover key traversal skips pending lanes in both directions. If every remaining lane is pending, Down stays on the real filter chips and Back returns to nav; a skeleton must never create an empty-column focus range. Discover key traversal skips pending lanes in both directions. If every remaining lane is pending, Down stays on the real filter chips and Back returns to nav; a skeleton must never create an empty-column focus range.
@ -82,6 +116,8 @@ Discover key traversal skips pending lanes in both directions. If every remainin
Native **1920×1080** before/after frames and the frame-budget inventory are published at [the motion build proof](https://pages.justinvisser.org/wolfskin/tv-motion-build/). Before frames use the pre-change surface sources in this same isolated worktree; after frames use the shipped Compose surfaces. No device access, installation or optimized APK is part of this lane. On-box acceptance remains: no series layout change after first content, Home scrolling no worse than **39.6% janky / p90 65 ms**. Native JVM rendering does not measure that GPU/frame-timing target. Native **1920×1080** before/after frames and the frame-budget inventory are published at [the motion build proof](https://pages.justinvisser.org/wolfskin/tv-motion-build/). Before frames use the pre-change surface sources in this same isolated worktree; after frames use the shipped Compose surfaces. No device access, installation or optimized APK is part of this lane. On-box acceptance remains: no series layout change after first content, Home scrolling no worse than **39.6% janky / p90 65 ms**. Native JVM rendering does not measure that GPU/frame-timing target.
The [round-2 Discover native proof](https://pages.justinvisser.org/wolfskin/tv-round2-discover/) publishes paired 1920×1080 frame strips and exact 16 ms alpha/translation/position series for ready, slow, failed, sustained rapid-selection and reduced-motion scenarios. The reviewed counter-fade remains available separately for comparison with the corrected sequential copy; initial-entry/remount and Home hero series are also published. The regression suite checks an opaque artwork base, at most one visible copy, monotonic per-phase motion, stationary controls and token-bounded outgoing artwork lifetimes. The approved fade-through may briefly have no visible copy; it never paints overlapping titles. These are native-rendering proofs, not on-box frame-pacing measurements.
## Card metadata and bottom state line — revision 6, option B ## Card metadata and bottom state line — revision 6, option B
@ -106,10 +142,57 @@ Justin's additional correction is binding even where a reference frame leaks: **
Native same-fixture comparisons and the exhaustive real-art bottom-row proof are published in [the card metadata build gallery](https://pages.justinvisser.org/wolfskin/tv-card-meta-build/). `CardBottomPixelTest` checks white Annie Hall, dark Locke, colourful Apocalypse Now and Avatar Aang at rest and individually focused; every pixel across each full bottom row must equal an art-free rendering of the same clipped backing/ring, while straight-edge and fill/track pixels additionally match exact state colours. It also checks Home/library parity when progress reaches or exceeds completion. `CardGeometryTest` measures narrow fitting, the 24 px check, grid/shelf pitches, pinned rows and ring clearances to 0.1 px, and asserts every pixel in reserved loading caption bands remains background-coloured. `CardCaptionMotionTest` compares native glyph pixels after changing a focused title and moving across a focused Home shelf. Reference comparisons retain documented renderer/fixture artifacts; unapproved visible design changes remain merge blockers. Native same-fixture comparisons and the exhaustive real-art bottom-row proof are published in [the card metadata build gallery](https://pages.justinvisser.org/wolfskin/tv-card-meta-build/). `CardBottomPixelTest` checks white Annie Hall, dark Locke, colourful Apocalypse Now and Avatar Aang at rest and individually focused; every pixel across each full bottom row must equal an art-free rendering of the same clipped backing/ring, while straight-edge and fill/track pixels additionally match exact state colours. It also checks Home/library parity when progress reaches or exceeds completion. `CardGeometryTest` measures narrow fitting, the 24 px check, grid/shelf pitches, pinned rows and ring clearances to 0.1 px, and asserts every pixel in reserved loading caption bands remains background-coloured. `CardCaptionMotionTest` compares native glyph pixels after changing a focused title and moving across a focused Home shelf. Reference comparisons retain documented renderer/fixture artifacts; unapproved visible design changes remain merge blockers.
## Details watched control
The on state has no unfocused ring: Justin (2026-11-07) read it as active/selected. This overrides the older IconButton “on = inset 3 px accent ring” and Watched “inset accent ring” descriptions in the written specification. The existing open-eye versus crossed-eye glyph, “Watched” header text, and “Mark as unwatched” action caption distinguish the state without a new colour or token. The focus ring, scale, fill and selected accessibility semantics are unchanged.
Source — Justin (2026-11-07): “the watches icon on details pages has a border, making it look like it's active/selected.”
## UI polish — revision 7 (2026-11-07)
Justin approved all twelve ideas on the [UI polish page](https://pages.justinvisser.org/wolfskin/ui-polish) and E01–E07 on the [everyday polish page](https://pages.justinvisser.org/wolfskin/ui-polish-everyday): "Do now". He approved the looks and copy exactly as the mocks show. Where this section and an earlier revision disagree, this section wins.
| Idea | Behaviour | Tokens |
|---|---|---|
| P01 chips | Discover and Library chip fill, text and selected border cross-fade between rest, selected and focused; resting pixels unchanged | `focusMove` 180 ms, `easeOut` |
| P02 toasts | Every recovery toast fades in, holds 3200 ms, then fades out; a new message replaces the text without a second toast | `base` 160 ms, `ease` |
| P03 Sending | The request button keeps its width and reads "Sending…" from OK until the service answers | `base` 160 ms crossfade |
| P04 receipt | The outcome block fades in once when the service answers; the form goes in the same frame | `lateLineFade` 200 ms, `ease` |
| P05 + E03 rows | A row that already entered during this screen visit (including after Back from a pushed page, and after scrolling back) does not enter again; a new visit (tab switch, person switch) and first appearances are unchanged | `rowEnter` / `rowRise` |
| P06 sheets | Requests, Request, Version, Audio and subtitles, Media info and the other right-hand sheets fade with their scrim; one scrim at a time; Menu ↔ sheet hand-offs keep the scrim constant | new `sheetIn` 160 ms / `sheetOut` 100 ms, `easeOut` |
| P07 loading | Requests and picker placeholders breathe on the shared skeleton clock; buttons never pulse | `skeletonCycle`, `breatheEase` |
| P08 person | After OK on a person, the others fade to background, then the picker does, and their Home follows with no sign-in page between | `base` ×2, `ease` |
| P09 readiness | Rows below an expanding or collapsing request slide; the readiness content appears at once and is uncovered by the sliding rows; window shifts still jump | `lateLineFade` 200 ms, `easeOut` |
| P10 empty Requests | "Nothing coming in." / "New requests show up here." / Close (focused) | — |
| P11 Library results | A changed sort or filter result fades up as one: all visible rows together | `rowEnter`, `rowRise` |
| P12 thumbnails | A request without a poster (none, or failed) shows a centred 40 px film or TV glyph in `dim` | new `requests.thumbGlyph` 40 px |
| E01 screen changes | No whole-screen transition anywhere in the shell or the setup host; the incoming screen's own entrance carries the motion | — |
| E02 focus ring | One card ring per shell surface moves between cards, changing x, y, width, height and radius together; card scale and lift unchanged; settled pixels unchanged | `navBlob` spring; `focusMove` alpha |
| E04 Play | Play/Resume reads "Starting…" while the stream is prepared; the page fades to black before the player opens | `base` 160 ms, `ease` |
| E05 scrolling | Every key-driven scroll is a critically damped spring that carries velocity across key repeats and never overshoots | `navBlob` spring |
| E06 progress | Title and card progress fill to the new point after playback; first paint never animates | `heroFade` 320 ms, `easeInOut` |
| E07 seasons | The selection capsule slides between season chips; a season seen before shows its episodes at once | `navBlob` spring |
Reduced motion (platform animator scale 0 or `LocalReducedMotion`): fades ≤ 80 ms; movement, springs, height and scroll snap; pulses stop. Motion never moves focus ownership; retained exit copies are inert and swallow pointer input. The layer budget is unchanged: one animated layer per shelf; overlays add one bounded panel layer; scrims, veils and the ring are draw-only.
Decisions taken while specifying, and why:
- P08: the mock's 90 ms pause between the two fades has no token, so the second fade starts as the first ends.
- P09: the request that leaves the bounded 5 → 4 window disappears at the start, as before.
- P10: Close is the approved loading-state pill; the second line uses the `receiptGap` spacing.
- P12: the glyph size needed a new look token.
- By Justin's standing rule ("if we do things similarly elsewhere, assume my instructions likely apply there too"), these also apply:
- E02 covers title-page shelves, cast circles and the person filmography;
- P05/E03 cover Home, Discover and Library alike;
- E07's season chips use P01's colour fade.
Native proofs: the `ui-polish/<ID>/` captures listed under Sources and verification.
## Sources and verification ## 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`. 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`.
Revision-7 polish proofs are listed in `docker/README.md` under UI polish.
`MotionSurfaceTest`, `HeroMotionTest`, `NetworkArtTransitionTest` and `DiscoverSkeletonKeysTest` cover late-line geometry, loading/ready row eligibility, Back/nav ownership, rendered pulse suppression under both motion controls, strict main-thread I/O policy, network-versus-cache reveal, bounded hero blending, delayed art and interrupted hand-offs. Native follow-up cases compare the pulse pixels of a late-joining shelf, hold caption translation through both settled and interrupted exits, and observe late ratings/genres/overview fading without moving their slots or actions. Manually clocked captures explicitly deliver snapshot changes before advancing animation time; interrupted-exit assertions sample after the hide has committed. The frozen prototype includes `index.html`, its local media, the original CSS/JS/builder and a complete SHA-256 manifest; replay the HTML, not the preserved builder's live-publishing entry point. `MotionSurfaceTest`, `HeroMotionTest`, `NetworkArtTransitionTest` and `DiscoverSkeletonKeysTest` cover late-line geometry, loading/ready row eligibility, Back/nav ownership, rendered pulse suppression under both motion controls, strict main-thread I/O policy, network-versus-cache reveal, bounded hero blending, delayed art and interrupted hand-offs. Native follow-up cases compare the pulse pixels of a late-joining shelf, hold caption translation through both settled and interrupted exits, and observe late ratings/genres/overview fading without moving their slots or actions. Manually clocked captures explicitly deliver snapshot changes before advancing animation time; interrupted-exit assertions sample after the hide has committed. The frozen prototype includes `index.html`, its local media, the original CSS/JS/builder and a complete SHA-256 manifest; replay the HTML, not the preserved builder's live-publishing entry point.
The feedback prototype delta links the native 1920×1080 before/after frames. `HomeViewportTest` exercises short and long poster/tile/wide rows, first focus, both row directions and native Back from the shipped title surface. The real-Bear capture harness reads recorded Jellyfin metadata through `TitleReadFacade`; it is a throwaway proof and is not shipped. The final gates are `make test` and `make apk-debug`. No TV access or optimized-APK build is part of this worker's validation. The feedback prototype delta links the native 1920×1080 before/after frames. `HomeViewportTest` exercises short and long poster/tile/wide rows, first focus, both row directions and native Back from the shipped title surface. The real-Bear capture harness reads recorded Jellyfin metadata through `TitleReadFacade`; it is a throwaway proof and is not shipped. The final gates are `make test` and `make apk-debug`. No TV access or optimized-APK build is part of this worker's validation.

View file

@ -200,6 +200,12 @@ execution order. Keep worker heap and parallelism unchanged between samples.
`PersonPickerSessionTest` must also pass late in the full app JVM, not just alone `PersonPickerSessionTest` must also pass late in the full app JVM, not just alone
or promoted to the front by previous failures. or promoted to the front by previous failures.
`SavedPeoplePickerTest` publishes pending Compose snapshots and pumps Android's
main queue while awaiting the saved household, just like the session-picker
await above. Its five-second deadline, blocked first network response and saved
identity assertions are unchanged; the virtual frame clock alone does not drain
an IO continuation queued on Android's host looper.
## Saved-person launch and DNS ## Saved-person launch and DNS
Launch restores an unprotected saved Jellyfin login from local storage before Launch restores an unprotected saved Jellyfin login from local storage before
@ -397,6 +403,12 @@ padding includes it. The 6 px dash / 4 px gap follows the browser's 3:2 stroke-w
ratios. Resting borders are white at 22% opacity; focused borders use ink at 40%. ratios. Resting borders are white at 22% opacity; focused borders use ink at 40%.
The small gear centred beneath the complete people row opens Settings with Requests focused. The small gear centred beneath the complete people row opens Settings with Requests focused.
Down from any avatar reaches it; Up returns to the originating avatar, including
after a Settings visit. Left/Right avatar traversal is unchanged.
The navigation requirement comes from Justin's words: “the settings icon moved,
but pressing down only works from the last profile icon. I want it to always
move there when pressing down in that screen”. The approved gear placement stays
centred beneath the complete people row.
Settings groups hand off to existing upstream pages; Requests opens the household Settings groups hand off to existing upstream pages; Requests opens the household
Coming-in sheet described below. **Advanced settings → Wolfskin screens** is the persistent Coming-in sheet described below. **Advanced settings → Wolfskin screens** is the persistent
rollout switch: turning it off renders upstream Home and library destinations with rollout switch: turning it off renders upstream Home and library destinations with
@ -405,11 +417,94 @@ Settings list. Native Up traversal stays in content until the first focusable ro
then enters the nav. Turning the switch on renders Wolfskin Home and Libraries. then enters the nav. Turning the switch on renders Wolfskin Home and Libraries.
Discover and Requests are integrated Wolfskin surfaces; title hand-offs remain labelled. Discover and Requests are integrated Wolfskin surfaces; title hand-offs remain labelled.
Root content Back focuses the nav; another Back exits the Android task via
`finishAndRemoveTask`, including Discover, Movies, Shows and Anime without a Home
detour. Cold picker Back exits; a reopened picker dismisses to nav first. Pushed
pages pop normally. Child dialogs, title menus, request sheets and unsaved-setting
prompts keep Back priority. Singleton full-screen pages also exit; they cannot
fall through to the older setup/user UI.
Source — Justin: “pressing back enough times should close wolfskin, currently
there is no way to go back to projectivity apart from weird methods via the old ui”.
The activity's `OnBackPressedDispatcher` owns native `KEYCODE_BACK`. It is not
mapped by `readShellKey`: a Compose key-down cannot navigate before the system
Back callback commits. Escape remains the local keyboard Back shortcut.
`rememberWolfskinBackHandler` resolves the live navigation entry and synchronizes
the shell route before the root nav/exit ladder. A title/person callback retained
during exit forwards to that live entry instead of popping its old title again;
correctness does not depend on a recomposed enabled flag.
`TitleEntryBackTest` mounts real `NavDisplay`, Home and loaded title/person
surfaces; `WolfskinRootExitTest` mounts actual Home/Discover/library surfaces.
Both run on SDK 34 and 36, including system/predictive callbacks, native key
ownership, immediate successive Back before recomposition, picker and Coming-in
precedence. `WolfskinBackStackTest` keeps the real Updates/Home Settings parent
pops and unsaved-edit protection.
Saved Jellyfin identities use the existing upstream session. Conductarr supplies Saved Jellyfin identities use the existing upstream session. Conductarr supplies
household labels, requester capabilities and library audiences after sign-in. household labels, requester capabilities and library audiences after sign-in.
The locked shell projection in `app/src/debug/assets/wolfskin-shell.json` is used The locked shell projection in `app/src/debug/assets/wolfskin-shell.json` is used
only when the Advanced debug fixture switch is enabled; release excludes it. only when the Advanced debug fixture switch is enabled; release excludes it.
### Household setup and sign-in recovery
With **Wolfskin screens** enabled, `MainContent` intercepts both upstream setup
destinations before their stock server/user selectors can render. Cold launches
that require sign-in, protected-profile restarts, the old drawer's user action,
legacy sign-out-equivalent state, setup fallbacks and intent failures all use
Wolfskin's own picker. Entering it does not call `SwitchUserViewModel.init` or
clear the remembered server/user preference.
Setup reuses the supplied/current/recent household server, recovering the most
recently used Room server if the legacy picker erased the preference. Saved people
render without a network request. Only an install with no saved server gets the
one-time Jellyfin URL field; it uses existing Jellyfin discovery and Room
persistence, not a new compiled endpoint. Conductarr remains on its existing
configured URL and enriches the signed-in shell as before.
Selecting a protected profile still requires its PIN or server credentials.
Expired credentials during saved-session revalidation or foreground refresh
return to the picker without allowing a late failure to replace another person's
session. A failed selection offers Wolfskin **Try again** and **Sign in** actions;
hidden/unsaved profiles can use username/password sign-in through the setup
picker's Settings gear. An outage never replaces the saved household endpoint.
With **Sign in automatically** off, ordinary returns from the background show the
profile picker. Only the first return after a trailer handoff keeps the originating
title and person: the same unprotected live identity and active signed-in entry
must match the pending handoff, exact title, same boot and under-ten-minute window.
Live protection is checked at stop and again when the stopped entry starts. The
handoff is cleared on return; a second, ordinary background return shows the picker.
Automatic sign-in on keeps its existing ordinary-return behaviour.
If Android kills Wolfskin while the trailer is in front, the same saved Android
task can continue for **strictly less than ten minutes from the handoff, on the
same boot**. Only an exported YouTube-handler launch from an authenticated,
unprotected title creates this return record. It stores server/person IDs, the
exact title route, elapsed time and boot counter in the Activity's saved Bundle:
no token, password, PIN or new last-person preference. Saving/recreating does not
renew the deadline; missing boot evidence keeps the picker.
Before installing a saved token, restoration re-reads the profile and requires
the exact most-recent server/person, a present token, no PIN and no require-login,
then rechecks the deadline. The recommendations worker may already have restored
that same unprotected canonical identity before setup leaves Loading; continuing
it does not install credentials again. A different current person, sign-out,
removed/tokenless profile or explicit picker cannot be replaced by the return
path. PIN/require-login people remain gated. A fresh task, fresh explicit intent,
wrong route or expired/missing record follows ordinary startup policy.
After identity acceptance, a one-use confirmation lets only that person skip the
new shell's launch chooser and reach the saved title. Ordinary cold automatic
sign-in still opens the chooser. `MainActivityTrailerRestoreTest` and
`TrailerReturnStateTest` cover the process-death window and identity gates;
`MainActivityResumeTest` and `SignedInContentTest` cover live return and protection
changes while away.
Turning **Wolfskin screens** off retains the unchanged upstream
`SwitchServerContent` and `SwitchUserContent` setup screens for rollback. Debug
fixtures still seed the existing fixture picker after authentication.
Person selection immediately clears the old Conductarr identity and Jellyfin Person selection immediately clears the old Conductarr identity and Jellyfin
read gate before saved-login I/O. Detached clients are disposed exactly once on read gate before saved-login I/O. Detached clients are disposed exactly once on
the application's IO scope, after the replacement (or signed-out) identity is the application's IO scope, after the replacement (or signed-out) identity is
@ -421,7 +516,7 @@ late reply cannot navigate back to an earlier person.
The fixture-mode marker belongs to the singleton `WolfskinSession`, alongside its The fixture-mode marker belongs to the singleton `WolfskinSession`, alongside its
server and shell state, not to the user-scoped ViewModel. Switching people replaces server and shell state, not to the user-scoped ViewModel. Switching people replaces
the upstream app entry and ViewModel but retains the same-server, same-mode shell: the upstream app entry and ViewModel but retains the same-server, same-mode shell:
the picker stays closed on Home after the first OK. A cold start still opens it, the picker stays closed on Home after the first OK. A fresh task still opens it,
and the nav avatar explicitly reopens it. `PersonPickerSessionTest` drives the real and the nav avatar explicitly reopens it. `PersonPickerSessionTest` drives the real
shell and user-entry replacement for launch/avatar Left and Right, the initial shell and user-entry replacement for launch/avatar Left and Right, the initial
person, gear and back, and fast Left-Left-OK; each selection emits one person action. person, gear and back, and fast Left-Left-OK; each selection emits one person action.
@ -486,21 +581,26 @@ the actual host's suspended fixture load and presses Android OK: it must open th
hero rather than activate the nav. Fixture completion does not steal a different hero rather than activate the nav. Fixture completion does not steal a different
destination or nav selection. destination or nav selection.
Native Roborazzi captures use Robolectric SDK 34 and Native Roborazzi captures normally use Robolectric SDK 34 and
`w960dp-h540dp-xhdpi` (1920×1080 at density 2.0). `make test` writes capture PNGs and `w960dp-h540dp-xhdpi` (1920×1080 at density 2.0). System-Back smokes also run SDK 36.
the sampled Compose nav-spring curve to `build/wolfskin-captures/`. `make test` writes capture PNGs and the sampled Compose nav-spring curve to
`build/wolfskin-captures/`.
Home, Library and Discover captures use `home/`, `library/` and `discover/` Home, Library and Discover captures use `home/`, `library/` and `discover/`
subdirectories so shared spec state numbers cannot overwrite another renderer's image. subdirectories so shared spec state numbers cannot overwrite another renderer's image.
Key replays exercise the Android event adapter, including the 6/8/10/11-press shell paths. Key replays exercise the Android event adapter, including the 6/8/10/11-press shell paths.
The profile hand-off capture targets the real `HomeSettingsPage`; Playback is a The profile hand-off capture targets the real `HomeSettingsPage`; Playback is a
separate press-count path. separate press-count path.
Robolectric SDK 28/30/34 runtimes resolve through Gradle's existing persistent Robolectric SDK 28/30/34/36 runtimes resolve through Gradle's existing persistent
dependency cache, then copy to `wolfskin-design/build/robolectric-sdk/`. Test workers dependency cache, then copy to `wolfskin-design/build/robolectric-sdk/`. Test workers
use that local directory in offline mode instead of downloading Android JARs into use that local directory in offline mode instead of downloading Android JARs into
the container's ephemeral home. A cache miss still needs Maven Central; rate-limited the container's ephemeral home. A cache miss still needs Maven Central; rate-limited
hosts can seed the existing cache from SHA512-verified local Maven artifacts without hosts can seed the existing cache from SHA512-verified local Maven artifacts without
changing the wrapper or adding volumes. changing the wrapper or adding volumes.
SDK 36 Back tests use the pinned Android 16 runtime and a test-worker-only
`--add-exports=java.base/jdk.internal.access=ALL-UNNAMED` for Robolectric's
`ApplicationSharedMemory` / JDK FileDescriptor bridge. Other capture suites
retain their existing SDK selection.
Default-flavour launcher artwork and English, Hungarian and Polish string overrides Default-flavour launcher artwork and English, Hungarian and Polish string overrides
live in `app/src/default/res/`. Legacy Wolfskin strings in 25 translated live in `app/src/default/res/`. Legacy Wolfskin strings in 25 translated
@ -515,7 +615,10 @@ attribution and project URL. The check does not depend on incremental merge file
## Wolfskin Home ## Wolfskin Home
Home is one lazy frame: a non-rotating resume → Next up → newest → pick hero, Home is one lazy frame: a non-rotating resume → Next up → newest → pick hero,
Jellyfin shelves, per-destination "Your …" shelves and the Discover continuation. one merged **Continue watching** lane, newest and per-destination Jellyfin shelves,
and the Discover continuation. The watch lane uses landscape tiles, prefers a
resumable episode over another next-up episode from the same series, and sorts
the chosen items by latest actual watch activity. The hero stays off the lane.
The final action opens the real merged Discover screen at its top through the The final action opens the real merged Discover screen at its top through the
shell's root re-selection transition. Home's See all cards open `WolfskinSeeAllLists`, carrying shell's root re-selection transition. Home's See all cards open `WolfskinSeeAllLists`, carrying
the complete underlying list rather than the visible shelf window. Title content the complete underlying list rather than the visible shelf window. Title content
@ -524,27 +627,45 @@ remains the later-slice target.
All Home components and immutable screen models live in `:wolfskin-design`. All Home components and immutable screen models live in `:wolfskin-design`.
The shell's remembered view owns vertical position, each row's selected card and The shell's remembered view owns vertical position, each row's selected card and
actual horizontal `LazyListState`, and the bounded six-hour ratings session cache. actual horizontal `LazyListState`, and the bounded six-hour ratings session cache.
Composing destinations bind `rememberedView(route)` before navigation effects run, so a Every `NavEntry` installs `ScreenEntry` with its own `rememberedView(route)` and
new See all grid cannot borrow or clamp Home's scroller. Re-entry restores the active state before navigation effects run. Home, Library, Discover, title and
remembered track before the page with `scrollToItem`, preserving exact indices and person surfaces consume that entry's viewport; an outgoing page cannot borrow
pixel offsets rather than animating back. Android focus stays with the shell; Home's scroller during Back. Only the active entry registers logical key handling
cards use its logical focus, without requests against uncomposed lazy items. or acquires Android focus. Re-entry restores the remembered horizontal track
with `scrollToItem`, preserving exact indices and pixel offsets. Cards use the
shell's logical focus without requests against uncomposed lazy items.
Back preserves the viewport; returning to Home does not re-run focus-follow over Back preserves the viewport; returning to Home does not re-run focus-follow over
a manually scrolled saved view. The next arrow re-enters visible content when a manually scrolled saved view. The next arrow re-enters visible content when
scrolling moved focus off-screen. scrolling moved focus off-screen.
Pointing changes focus without activation, cards activate on key-up, and held Pointing changes focus without activation, cards activate on key-up, and held
OK events cannot activate twice. OK events cannot activate twice.
Between shelves, an unvisited row selects the nearest visible card center even Between Home shelves, an unvisited row starts at its leftmost card. A visited row
when card widths differ. A visited row restores its column. Horizontal movement restores its column and first-visible position, including after leaving and
keeps the track still while focus remains in its visible middle. returning to the Home tab; Home memory is not reset on tab leave. Library keeps
standard grid navigation. Horizontal movement keeps the track still while focus
remains in its visible middle. Title/person Android Back handlers use the same
active-entry ownership flag, so outgoing pages cannot consume a second Back.
The initial skeleton parks focus on the nav. When the first content arrives, The initial skeleton parks focus on the nav. When the first content arrives,
an untouched fresh entry focuses hero button zero; intervening nav input prevents an untouched fresh entry focuses hero button zero; intervening nav input prevents
that automatic transfer. Home, Libraries and Discover use the same `ShellContentFocus` that automatic transfer. Home, Libraries and Discover use the same `ShellContentFocus`
release/repeat guard and Back ladder. release/repeat guard and Back ladder.
Landscape title scrims include the reserved state-line area inside their gradient
rather than leaving an exposed image strip below it. Their top padding is the
shared Home token (18 dp); opacity remains the approved 92%.
The legacy opt-out Home and its presets also use one combined watch row. Saved
local/server/web/plugin split rows migrate at their first position, preserving
that row's display options and all unrelated rows. Old serialized row names stay
decodable; settings no longer offer separate lanes or a Split action.
`HomeReadFacade` confines Home SDK reads to the selected user. Resume and Next up `HomeReadFacade` confines Home SDK reads to the selected user. Resume and Next up
publish independently of slow or failed library shelves; saved metadata still publish independently of slow or failed library shelves; saved metadata still
paints immediately, but a saved hero offers Details until watch data revalidates. paints immediately, but a saved hero offers Details until watch data revalidates.
Series chronology reads the latest actually watched episode, not the next-up
episode's unwatched user data or premiere date. Enrichment is bounded to three
concurrent series reads; absent history leaves the date unknown rather than
inventing a timestamp. The disk projection preserves series IDs and watch dates,
and still accepts older cache entries without either field.
Home and title Play actions resolve the current Jellyfin item and its current Home and title Play actions resolve the current Jellyfin item and its current
checkpoint at the click, preserving millisecond positions. Series actions use checkpoint at the click, preserving millisecond positions. Series actions use
live NextUp with resumable episodes enabled; an explicitly opened episode stays live NextUp with resumable episodes enabled; an explicitly opened episode stays
@ -806,24 +927,31 @@ The [revision-5 amendment](../data/tv-spec/spec.md) and
[native frame prototype delta](../data/tv-v2/feedback-2026-11-06/index.html) [native frame prototype delta](../data/tv-v2/feedback-2026-11-06/index.html)
retain the approved frames except for the requested feedback changes: retain the approved frames except for the requested feedback changes:
- The picker gear is centred under everyone and uses the picker-person ring. - The picker gear retains its approved whole-row centring and picker-person ring.
Down from every avatar reaches it; Up returns to the originating avatar.
- Shelf tracks share second-last-slot lookahead, count clamping and settled - Shelf tracks share second-last-slot lookahead, count clamping and settled
offsets. Home records completed frame positioning rather than an animation offsets. Home records completed frame positioning rather than an animation
that may still be cancelled; first mount restores the intended shelf item. that may still be cancelled; first mount restores the intended shelf item.
Discover uses all six approved poster slots. Person keeps the final column Discover uses all six approved poster slots. Person keeps the final column
at full poster width. at full poster width.
- Episode images choose episode Primary, then Thumb, then the already-read - Episode images choose episode Primary, then Thumb, then the already-read series Backdrop.
series Backdrop. Episode cards are 400×280 px; cast retains its 68 px clearance. Title episode cards now use 448×280 px, preserving the taller approved crop.
Episode context uses the episode overview, with two summary lines on the Focused summaries ellipsise to one line, the show-name overlay is absent, and
focused card. Held-series headers measure their copy/actions and leave 40 px `S1 E3 · duration` plus the watched check share one line.
before seasons instead of reserving a fixed empty 900 px header. Ready title headers measure their copy/actions and leave 40 px before the
following section. Missing readiness lines collapse completely on every title
kind; late lines expand/collapse over the 200 ms late-line token with ease-out,
or snap with reduced motion. Already-pinned rows compensate in the same
remeasure rather than bouncing through a second focus-follow animation.
Poster-only movies floor that stack so Cast circles clear the poster by at
least 20 px, including held movie fallbacks and short overviews.
- Every Discover heading uses a 104 px band, including a 60 px title-to-card - Every Discover heading uses a 104 px band, including a 60 px title-to-card
gap. Home heading geometry is unchanged. gap. Home heading geometry is unchanged.
- Hero/backdrop decoding is bounded to 1920×1080; upright hero sheets request - Hero/backdrop decoding is bounded to 1920×1080; upright hero sheets request
420×630. The shared request builder replaces conflicting Jellyfin size/encoding 420×630. The shared request builder replaces conflicting Jellyfin size/encoding
parameters: cards use WebP quality 80, while hero backdrops/sheets retain JPEG 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 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 448×280; Jellyfin fill preserves aspect ratio before the UI crops. Tags and
authentication query parameters remain intact. TMDB uses w342 for posters and authentication query parameters remain intact. TMDB uses w342 for posters and
portraits, w780 for landscape cards and w1280 for hero backdrops, never original. portraits, w780 for landscape cards and w1280 for hero backdrops, never original.
Lossy WebP decoding is guaranteed below minSdk 23; API 28+ additionally checks Lossy WebP decoding is guaranteed below minSdk 23; API 28+ additionally checks
@ -831,9 +959,12 @@ retain the approved frames except for the requested feedback changes:
cache, 128 MiB disk cache and four image-read limit remain unchanged. cache, 128 MiB disk cache and four image-read limit remain unchanged.
`HomeViewportTest` covers short/long poster, tile and wide rows, both row `HomeViewportTest` covers short/long poster, tile and wide rows, both row
directions, first focus and native Back from the actual title surface. Native directions, first focus and exact scroll/card bounds after native Back from the
proof records real Bear season-three metadata through the title read facade; actual title surface. `TitleEntryBackTest` also checks the production Home/title
the proof-only harness is not part of the shipped tests or APK. loaders through `NavDisplay` and its entry decorators on SDK 34 and 36, with
animation and reduced motion. 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, Episode lists request only ProviderIds and Overview. Next up reads only identity,
then hydrates the selected playback target through the existing single-item read, then hydrates the selected playback target through the existing single-item read,
@ -863,6 +994,10 @@ Selections persist through the same playback repository as the upstream player.
Marking a whole series asks first; an explicit episode remains the exact play and Marking a whole series asks first; an explicit episode remains the exact play and
watched target even while another season is selected. Nested person/title visits watched target even while another season is selected. Nested person/title visits
retain their entry's focus and selection state. retain their entry's focus and selection state.
Discover Details opens this same title destination without replacing the outgoing
Discover page with a separate preview first. The shared destination owns its
initial loading skeleton; an already-ready title keeps its header and actions
through catalogue, held-title and fixture refreshes.
Choosing another episode from an episode page replaces only the top navigation Choosing another episode from an episode page replaces only the top navigation
entry, preserving its series/Home parent. Choosing the first episode from a entry, preserving its series/Home parent. Choosing the first episode from a
series, related titles, cast and performer credits still push normal drill-ins. series, related titles, cast and performer credits still push normal drill-ins.
@ -875,6 +1010,44 @@ pop a pushed title/person from content **or** nav. Initial loading has no conten
target: Down stays in nav, while Back can still leave the title. A missing rating target: Down stays in nav, while Back can still leave the title. A missing rating
set emits no layout node, so it cannot reserve an invisible extra facts gap. set emits no layout node, so it cannot reserve an invisible extra facts gap.
The title Menu replaces the old More/media-info icon action and contains Media
info plus Add for someone when requests are permitted. It is shared by held
movie, series and episode pages; non-held pages expose Add for someone in the
same menu while Request/status and Not interested/Show again stay in the row.
Version, tracks, watched and trailer controls keep their existing positions.
Back from Media info returns to the menu; Back from the menu restores its
opening button. The compact menu fades in for 160 ms and out for 100 ms using
the motion ease-out token, with reduced-motion fades capped at 80 ms. Only the
bounded menu panel owns an alpha layer; the scrim is static. Opening another
sheet immediately removes the menu panel and scrim, preventing double darkening.
An empty menu closes if its last permitted action disappears. Controls
use the shared 4 px straddle focus ring. Neither series nor episode details have
a redundant Seasons shortcut; Down reaches the season chips directly. Ratings
appear only in the header facts line, not again beside progress. Explicit episode
pages use the opened episode's own set, including its `Ep` score; movie and series
pages keep their own sets.
The watched on state has no unfocused ring: Justin (2026-11-07) read it as
active/selected — “the watches icon on details pages has a border, making it
look like it's active/selected.” The open-eye/crossed-eye glyph, Watched header
and Mark as unwatched caption still communicate the state. The shared focus
ring is unchanged. `TitleCaptureTest.watchedRingBelongsOnlyToFocus` records the
same Details row in all four watched/focus combinations and checks its rendered
edge pixels.
Episode shelf skeletons use the same width, height and pitch as ready cards.
Person filmography and related-title shelves remain posters; Home continuation
tiles are a separate component and retain their series-name context.
Season episode reels use episode-number order independently of the Play/Resume
or explicitly opened episode. The shared live/saved projection keeps rich
playback metadata without promoting that target into the first tile. A refresh
therefore cannot replace the episode under a remembered reel index merely
because the playback target changed. `TitleSeasonReelTest` replays native remote
input on SDK 34 and 36 with 24 season-five episodes and next-up at E24, including
row return, exact activation, cached reads and an explicit-episode refresh.
`TitleReadFacade` confines SDK reads, server/user-scoped last-good disk copies, `TitleReadFacade` confines SDK reads, server/user-scoped last-good disk copies,
person credits and playback mutations to one boundary. `ConductarrTitlePages` person credits and playback mutations to one boundary. `ConductarrTitlePages`
adds real detail, cast, related titles, performer/filmography, readiness/receipts adds real detail, cast, related titles, performer/filmography, readiness/receipts
@ -884,6 +1057,16 @@ votes enrich those real episode identities; no catalogue stub becomes playable.
Debug-only `wolfskin-titles.json` and local artwork remain behind the explicit Debug-only `wolfskin-titles.json` and local artwork remain behind the explicit
Advanced fixture switch; release never packages or selects them. Advanced fixture switch; release never packages or selects them.
Held title pages use the existing upstream `ThemeSongPlayer` while their current
navigation entry is resumed. Episodes use the series Jellyfin id, never the
episode id; catalogue-only titles have no theme identity. The existing
`AppPreference.PlayThemeMusic` controls playback (Disabled is silent; High is
volume 0.5). Pause, disposal, outgoing navigation and person/session changes
release the theme; video and trailer hand-offs stop it before opening playback.
`TitleThemeMusicTest` exercises these ownership transitions with a fake player
and the real native title destination. Jellyfin still needs an indexed local
`theme.*` file supplied by the library publisher; empty theme lists stay silent.
Held trailers use Jellyfin remote/local trailer data. YouTube URLs target an Held trailers use Jellyfin remote/local trailer data. YouTube URLs target an
installed YouTube handler such as SmartTube, never a browser. No handler leaves installed YouTube handler such as SmartTube, never a browser. No handler leaves
the title visible with a calm message. Titles nobody holds have no Trailer the title visible with a calm message. Titles nobody holds have no Trailer
@ -899,7 +1082,27 @@ headers, sheets, cast and poster captions without changing shared type tokens.
Fractional stack placement rounds only the accumulated position, preventing a Fractional stack placement rounds only the accumulated position, preventing a
one-pixel-per-row drift. Focused controls share the canonical lift shadow. one-pixel-per-row drift. Focused controls share the canonical lift shadow.
`TitleKeysTest` replays real Android keys, including modal Back/hold, nearest-row `TitleKeysTest` replays real Android keys, including modal Back/hold, nearest-row
entry, remembered columns, long-sheet visibility and full media-table geometry. entry, remembered columns and long-sheet visibility. `TitleRound2Test` covers
menu Back/focus, preserved media info and household requests, movie stacking,
the Mussolini resume example, request revocation, episode skeleton geometry,
and native menu fade frames sampled in 16 ms steps.
**UI polish (revision 7).** The tests below write 16 ms frame strips and `samples.csv` to `build/wolfskin-captures/ui-polish/<ID>/`:
- `RequestsPolishTest` (P06 Requests, P07, P09, P10, P12)
- `DiscoverPolishTest` (P01, P03, P04, P06 Request sheet, E03 lanes)
- `LibraryPolishTest` (P01 toolbar, P05, P11)
- `HomePolishRowsTest` (E03)
- `RecoveryToastMotionTest` (P02)
- `FocusGlideTest` (E02)
- `ScrollMomentumTest` (E05)
- `PickerPolishTest` (P07 picker)
- app `TitlePolishTest` (P06 title sheets, E04 title, E06, E07)
- app `PersonCommitTest` (P08)
- app `NavigationCutTest` (E01)
- app `HomeHeroStartingTest` (E04 Home)
`TitleRound2Test.menuToMediaInfoNeverDoubleDimsOrPopsAndBackKeepsOneScrim` now asserts one constant scrim and a monotonic panel fade. `RequestsKeysTest` asserts that OK on the empty Requests Close closes the sheet.
`TitleAsyncFocusTest` delays title and person replies through the real destination `TitleAsyncFocusTest` delays title and person replies through the real destination
and view-model, then exercises content focus and hardware Back. Subtitles Off and view-model, then exercises content focus and hardware Back. Subtitles Off
maps to the player's explicit disabled sentinel, not its unspecified default. maps to the player's explicit disabled sentinel, not its unspecified default.

View file

@ -120,6 +120,8 @@ androidx-media3-exoplayer-dash = { module = "androidx.media3:media3-exoplayer-da
androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "androidx-media3" } androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "androidx-media3" }
androidx-media3-ui-compose = { module = "androidx.media3:media3-ui-compose", version.ref = "androidx-media3" } androidx-media3-ui-compose = { module = "androidx.media3:media3-ui-compose", version.ref = "androidx-media3" }
androidx-media3-effect = { group = "androidx.media3", name = "media3-effect", version.ref = "androidx-media3" } androidx-media3-effect = { group = "androidx.media3", name = "media3-effect", version.ref = "androidx-media3" }
androidx-media3-test-utils = { module = "androidx.media3:media3-test-utils", version.ref = "androidx-media3" }
androidx-media3-test-utils-robolectric = { module = "androidx.media3:media3-test-utils-robolectric", version.ref = "androidx-media3" }
exoplayerhdrutils = { module = "com.suyashbelekar:exoplayerhdrutils", version.ref = "exoplayerhdrutils" } exoplayerhdrutils = { module = "com.suyashbelekar:exoplayerhdrutils", version.ref = "exoplayerhdrutils" }
coil-core = { module = "io.coil-kt.coil3:coil-core", version.ref = "coil" } coil-core = { module = "io.coil-kt.coil3:coil-core", version.ref = "coil" }

View file

@ -24,6 +24,7 @@ val cacheRobolectricSdk by tasks.registering(Copy::class) {
"9-robolectric-4913185-2-i7", "9-robolectric-4913185-2-i7",
"11-robolectric-6757853-i7", "11-robolectric-6757853-i7",
"14-robolectric-10818077-i7", "14-robolectric-10818077-i7",
"16-robolectric-13921718-i7",
) )
// One configuration would evict the older SDKs through version conflict resolution. // One configuration would evict the older SDKs through version conflict resolution.
from(versions.map { version -> from(versions.map { version ->

View file

@ -50,9 +50,9 @@
}, },
"cardOverlayTop": { "cardOverlayTop": {
"$type": "dimension", "$type": "dimension",
"px": 54, "px": 36,
"dp": 27.0, "dp": 18.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289" "source": "TV round 2: lower card bottom gradient; cover the state-line reserve"
}, },
"cardOverlayBottom": { "cardOverlayBottom": {
"$type": "dimension", "$type": "dimension",

View file

@ -30,6 +30,7 @@
"receiptPaddingX": { "$type": "dimension", "px": 30, "dp": 15, "source": "index.html:243" }, "receiptPaddingX": { "$type": "dimension", "px": 30, "dp": 15, "source": "index.html:243" },
"receiptGap": { "$type": "dimension", "px": 10, "dp": 5, "source": "index.html:243" }, "receiptGap": { "$type": "dimension", "px": 10, "dp": 5, "source": "index.html:243" },
"receiptGlyph": { "$type": "dimension", "px": 36, "dp": 18, "source": "index.html:245" }, "receiptGlyph": { "$type": "dimension", "px": 36, "dp": 18, "source": "index.html:245" },
"thumbGlyph": { "$type": "dimension", "px": 40, "dp": 20, "source": "ui-polish P12, approved 2026-11-07: film/TV glyph centred in a request thumbnail without a poster" },
"buttonMargin": { "$type": "dimension", "px": 12, "dp": 6, "source": "index.html:241" }, "buttonMargin": { "$type": "dimension", "px": 12, "dp": 6, "source": "index.html:241" },
"skeletonTitleWidth": { "$type": "dimension", "px": 360, "dp": 180, "source": "index.html:1902" }, "skeletonTitleWidth": { "$type": "dimension", "px": 360, "dp": 180, "source": "index.html:1902" },
"skeletonTitleHeight": { "$type": "dimension", "px": 34, "dp": 17, "source": "index.html:1902" }, "skeletonTitleHeight": { "$type": "dimension", "px": 34, "dp": 17, "source": "index.html:1902" },

View file

@ -99,6 +99,8 @@ class HomeFixture(context: Context) {
resumeSeconds = item.optDouble("resumeSeconds", 0.0).takeIf { it.isFinite() } ?: 0.0, resumeSeconds = item.optDouble("resumeSeconds", 0.0).takeIf { it.isFinite() } ?: 0.0,
played = item.optBoolean("played"), addedAt = item.optString("addedAt", ""), genres = genres(item).toList(), ratings = ratings(item), played = item.optBoolean("played"), addedAt = item.optString("addedAt", ""), genres = genres(item).toList(), ratings = ratings(item),
held = item.optString("availability") == "in_library", tmdbId = item.intOrNull("tmdbId"), held = item.optString("availability") == "in_library", tmdbId = item.intOrNull("tmdbId"),
seriesId = item.stringOrNull("seriesId"),
lastPlayedAt = item.stringOrNull("lastPlayedAt")?.let { java.time.Instant.parse(it).toEpochMilli() },
) )
private fun ratings(item: JSONObject): List<HomeRating> { private fun ratings(item: JSONObject): List<HomeRating> {

View file

@ -1,14 +1,24 @@
// SPDX-License-Identifier: GPL-2.0-only // SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design package org.justinvisser.wolfskin.design
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationVector1D
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.LazyListLayoutInfo
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -16,6 +26,13 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.layout.layout
import kotlin.math.roundToInt
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
@ -29,6 +46,7 @@ import androidx.tv.material3.Text
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePainter import coil3.compose.AsyncImagePainter
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
import org.justinvisser.wolfskin.design.WolfskinTokens.Requests as T import org.justinvisser.wolfskin.design.WolfskinTokens.Requests as T
@ -36,10 +54,19 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Requests as T
/** The overlay intercepts pointer input as well as the shell's virtual D-pad focus. */ /** The overlay intercepts pointer input as well as the shell's virtual D-pad focus. */
@Composable @Composable
fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) { fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) {
val visible = shell.requestsVisible
val fade = rememberSheetFade(visible)
if (!visible && fade.value == 0f) return
val state = shell.requests val state = shell.requests
val data = state.snapshot val current = RequestsPanelFrame(state.snapshot, state.focusedId, state.expanded, state.windowStart, state.windowSize,
state.recoveryFocused, state.ratingsVisible, shell.requestsExpansionInput)
val retained = remember { mutableStateOf(current) }
SideEffect { if (visible) retained.value = current }
val frame = if (visible) current else retained.value
val data = frame.data
val ratingsReader = LocalScreenRatings.current val ratingsReader = LocalScreenRatings.current
LaunchedEffect(state, state.focusedId, data.signedIn, data.status) { LaunchedEffect(visible, state, state.focusedId, data.signedIn, data.status) {
if (!visible) return@LaunchedEffect
if (ratingsReader == null) state.awaitFocusedRatings() if (ratingsReader == null) state.awaitFocusedRatings()
else if (data.signedIn) state.focused?.let { item -> else if (data.signedIn) state.focused?.let { item ->
ratingsReader.read(item.tmdbId, item.mediaType, null, null, cachedOnly = data.status == ComingInStatus.Unavailable)?.let { values -> ratingsReader.read(item.tmdbId, item.mediaType, null, null, cachedOnly = data.status == ComingInStatus.Unavailable)?.let { values ->
@ -50,8 +77,9 @@ fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) {
} }
} }
} }
Box(modifier.fillMaxSize().background(C.sheetScrim).clickable { shell.closeRequests() }.testTag("coming-in-scrim")) Box(modifier.fillMaxSize().sheetScrim { fade.value }.focusProperties { canFocus = false }
Column(modifier.fillMaxHeight().width(L.sheetWidth) .clickable { if (visible) shell.closeRequests() }.testTag("coming-in-scrim"))
Column(modifier.sheetPanelFade { fade.value }.fillMaxHeight().width(L.sheetWidth)
.offset(x = WolfskinTokens.Shell.stageWidth - L.sheetWidth) .offset(x = WolfskinTokens.Shell.stageWidth - L.sheetWidth)
.drawWithCache { .drawWithCache {
val panel = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply { val panel = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
@ -61,9 +89,11 @@ fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) {
onDrawBehind { onDrawBehind {
drawIntoCanvas { it.nativeCanvas.drawRect(0f, 0f, size.width, size.height, panel) } drawIntoCanvas { it.nativeCanvas.drawRect(0f, 0f, size.width, size.height, panel) }
} }
}.pointerInput(Unit) { }.focusProperties { canFocus = visible }
.then(if (visible) Modifier else Modifier.clearAndSetSemantics { })
.pointerInput(visible) {
awaitPointerEventScope { awaitPointerEventScope {
while (true) awaitPointerEvent(PointerEventPass.Final).changes.forEach { it.consume() } while (true) awaitPointerEvent(if (visible) PointerEventPass.Final else PointerEventPass.Initial).changes.forEach { it.consume() }
} }
} }
.padding(horizontal = T.sheetPaddingX, vertical = T.sheetPaddingY) .padding(horizontal = T.sheetPaddingX, vertical = T.sheetPaddingY)
@ -71,10 +101,10 @@ fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) {
Text("Coming in", Modifier.comingInLineBox(T.sheetTitleLineHeight, T.sheetTitleBaseline), style = ComingInTypography.title, color = C.fg) Text("Coming in", Modifier.comingInLineBox(T.sheetTitleLineHeight, T.sheetTitleBaseline), style = ComingInTypography.title, color = C.fg)
when { when {
data.status == ComingInStatus.Loading && data.items.isEmpty() -> { data.status == ComingInStatus.Loading && data.items.isEmpty() -> {
LazyColumn(verticalArrangement = Arrangement.spacedBy(T.listGap)) { LazyColumn(Modifier.skeletonPulse(), verticalArrangement = Arrangement.spacedBy(T.listGap)) {
items(3, key = { "request-skeleton-$it" }, contentType = { "skeleton" }) { ComingInSkeleton() } items(3, key = { "request-skeleton-$it" }, contentType = { "skeleton" }) { ComingInSkeleton() }
} }
SheetButton("Close") { shell.closeRequests() } SheetButton("Close", interactive = visible) { shell.closeRequests() }
} }
data.status == ComingInStatus.Unavailable && data.items.isEmpty() -> { data.status == ComingInStatus.Unavailable && data.items.isEmpty() -> {
Column(Modifier.fillMaxWidth().background(C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.panel)) Column(Modifier.fillMaxWidth().background(C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.panel))
@ -86,34 +116,50 @@ fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) {
} }
Text("Conductarr didn’t answer.", style = ComingInTypography.body, color = C.muted) Text("Conductarr didn’t answer.", style = ComingInTypography.body, color = C.muted)
} }
SheetButton("Try again", primary = true) { shell.retryRequests() } SheetButton("Try again", primary = true, interactive = visible) { shell.retryRequests() }
}
data.items.isEmpty() -> {
Column(Modifier.padding(top = T.emptyMargin), verticalArrangement = Arrangement.spacedBy(T.receiptGap)) {
Text("Nothing coming in.", style = ComingInTypography.body, color = C.muted)
Text("New requests show up here.", style = ComingInTypography.body, color = C.muted)
}
SheetButton("Close", interactive = visible) { shell.closeRequests() }
} }
data.items.isEmpty() -> Text("Nothing coming in.", Modifier.padding(top = T.emptyMargin), style = ComingInTypography.body, color = C.muted)
else -> { else -> {
if (data.status == ComingInStatus.Unavailable) Text("Conductarr didn’t answer.", style = ComingInTypography.caption, color = C.warn) if (data.status == ComingInStatus.Unavailable) Text("Conductarr didn’t answer.", style = ComingInTypography.caption, color = C.warn)
data.savedAt?.takeIf { data.status == ComingInStatus.Unavailable }?.let { SavedPill(it) } data.savedAt?.takeIf { data.status == ComingInStatus.Unavailable }?.let { SavedPill(it) }
if (data.partial) Text("Some requests may be missing: Conductarr answered only in part.", style = ComingInTypography.caption, color = C.warn) if (data.partial) Text("Some requests may be missing: Conductarr answered only in part.", style = ComingInTypography.caption, color = C.warn)
val start = state.windowStart val start = frame.start
val visibleRows = remember(data, start, state.windowSize) { val listState = rememberLazyListState()
data.ordered.subList(start, (start + state.windowSize).coerceAtMost(data.ordered.size)) val placement = rememberRequestPlacement(frame, listState)
val visibleRows = remember(data, start, frame.windowSize) {
data.ordered.subList(start, (start + frame.windowSize).coerceAtMost(data.ordered.size))
} }
LazyColumn(Modifier.clipToBounds().then(if (data.status == ComingInStatus.Unavailable) Modifier.weight(1f, fill = false) else Modifier), verticalArrangement = Arrangement.spacedBy(T.listGap)) { LazyColumn(Modifier.clipToBounds().then(if (data.status == ComingInStatus.Unavailable) Modifier.weight(1f, fill = false) else Modifier),
itemsIndexed(visibleRows, state = listState, verticalArrangement = Arrangement.spacedBy(T.listGap)) {
key = { _, item -> item.id }, contentType = { _, _ -> "request" }) { _, item -> val keys = visibleRows.map { it.id }.uniqueKeys()
ComingInRow(item, shell.person, !state.recoveryFocused && item.id == state.focusedId, state.expanded && item.id == state.focusedId, itemsIndexed(visibleRows, key = { index, _ -> keys[index] }, contentType = { _, _ -> "request" }) { index, item ->
state.ratingsVisible && data.signedIn && item.id == state.focusedId, ComingInRow(item, shell.person, !frame.recoveryFocused && item.id == frame.focusedId, frame.expanded && item.id == frame.focusedId,
onFocus = { state.focus(item.id) }, onClick = { state.focus(item.id); shell.activateRequest() }) frame.ratingsVisible && data.signedIn && item.id == frame.focusedId,
onFocus = { if (visible) state.focus(item.id) }, onClick = { if (visible) { state.focus(item.id); shell.activateRequest() } },
modifier = Modifier.layout { measurable, constraints ->
val row = measurable.measure(constraints)
layout(row.width, row.height) {
row.placeRelative(0, placement.offset(keys[index], listState.layoutInfo))
}
},
readinessProgress = { placement.progress.value }, interactive = visible)
} }
} }
if (data.status == ComingInStatus.Unavailable) SheetButton("Try again", primary = true, focused = state.recoveryFocused) { shell.retryRequests() } if (data.status == ComingInStatus.Unavailable) SheetButton("Try again", primary = true, focused = frame.recoveryFocused, interactive = visible) { shell.retryRequests() }
} }
} }
} }
} }
@Composable @Composable
private fun SheetButton(label: String, primary: Boolean = false, focused: Boolean = true, onClick: () -> Unit) { private fun SheetButton(label: String, primary: Boolean = false, focused: Boolean = true, interactive: Boolean = true, onClick: () -> Unit) {
Box(Modifier.padding(top = T.buttonMargin)) { PickerButton(label, focused, primary, onClick = onClick) } Box(Modifier.padding(top = T.buttonMargin)) { PickerButton(label, focused, primary, onClick = { if (interactive) onClick() }) }
} }
@Composable @Composable
@ -128,32 +174,95 @@ private fun ComingInSkeleton() {
} }
} }
private data class RequestsPanelFrame(val data: ComingInSnapshot, val focusedId: String?, val expanded: Boolean,
val start: Int, val windowSize: Int, val recoveryFocused: Boolean, val ratingsVisible: Boolean, val expansionInput: Long)
private class RequestPlacementMemory(var frame: RequestsPanelFrame, var motion: RequestsPlacement? = null)
/** Placement reads one clock; target-offset caching happens only when the list is measured. */
private class RequestsPlacement(val animate: Boolean, private val origins: Map<Any, Int>) {
val progress: Animatable<Float, AnimationVector1D> = Animatable(if (animate) 0f else 1f)
private var measured: LazyListLayoutInfo? = null
private var targets: Map<Any, Int> = emptyMap()
fun offset(key: Any, info: LazyListLayoutInfo): Int {
val progress = progress.value
if (!animate || progress == 1f) return 0
if (measured !== info) {
targets = info.visibleItemsInfo.associate { it.key to it.offset }
measured = info
}
val target = targets[key] ?: return 0
return (((origins[key] ?: target) - target) * (1f - progress)).roundToInt()
}
}
/** Only remote expansion toggles animate; interruptions start at the current visual placement. */
@Composable
private fun rememberRequestPlacement(frame: RequestsPanelFrame, listState: LazyListState): RequestsPlacement {
val previous = remember { RequestPlacementMemory(frame) }
val reduced = reducedMotion()
val motion = remember(frame, reduced) {
val animate = !reduced && previous.frame.let {
it.expansionInput != frame.expansionInput && it.data === frame.data && it.focusedId == frame.focusedId &&
it.start == frame.start && it.expanded != frame.expanded
}
val info = listState.layoutInfo
val origins = if (animate) info.visibleItemsInfo.associate {
it.key to it.offset + (previous.motion?.offset(it.key, info) ?: 0)
} else emptyMap()
RequestsPlacement(animate, origins)
}
SideEffect { previous.frame = frame; previous.motion = motion }
LaunchedEffect(motion) {
if (motion.animate) motion.progress.animateTo(1f, tween(M.lateLineFade, easing = M.easeOut))
}
return motion
}
/** Thumbnail slots do not resize when the text or the readiness block grows. */ /** Thumbnail slots do not resize when the text or the readiness block grows. */
@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class, ExperimentalLayoutApi::class) @OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class, ExperimentalLayoutApi::class)
@Composable @Composable
fun ComingInRow(item: ComingInRequest, viewer: ShellPerson, focused: Boolean, expanded: Boolean, fun ComingInRow(item: ComingInRequest, viewer: ShellPerson, focused: Boolean, expanded: Boolean,
showRatings: Boolean, onFocus: () -> Unit, onClick: () -> Unit) { showRatings: Boolean, onFocus: () -> Unit, onClick: () -> Unit, modifier: Modifier = Modifier,
readinessProgress: () -> Float = { 1f }, interactive: Boolean = true) {
val foreground = if (focused) C.onFocus else C.fg val foreground = if (focused) C.onFocus else C.fg
val secondary = if (focused) C.onFocus else C.muted val secondary = if (focused) C.onFocus else C.muted
val context = LocalContext.current val context = LocalContext.current
val request = remember(item.poster, context) { val request = remember(item.poster, context) {
item.poster?.let { WolfskinImages.request(context, it, (L.thumbWidth.value * 2).toInt(), (L.thumbHeight.value * 2).toInt(), true) } item.poster?.let { WolfskinImages.request(context, it, (L.thumbWidth.value * 2).toInt(), (L.thumbHeight.value * 2).toInt(), true) }
} }
val posterReady = remember(request) { mutableStateOf(false) } val posterState = remember(request) { mutableStateOf<AsyncImagePainter.State>(AsyncImagePainter.State.Empty) }
Row(Modifier.fillMaxWidth().focusScale(focused, WolfskinTokens.Focus.scaleRow) var collapsedHeight by remember { mutableIntStateOf(0) }
Row(modifier.fillMaxWidth().testTag("coming-row-${item.id}")
.onSizeChanged { if (!expanded) collapsedHeight = it.height }
.drawWithContent {
val progress = readinessProgress()
if (expanded && collapsedHeight > 0 && progress < 1f) {
val bottom = collapsedHeight + (size.height - collapsedHeight) * progress
clipRect(left = -size.width, top = -size.height, right = size.width * 2, bottom = bottom) {
this@drawWithContent.drawContent()
}
} else drawContent()
}.focusScale(focused, WolfskinTokens.Focus.scaleRow)
.background(if (focused) C.fg else Color.Transparent, RoundedCornerShape(WolfskinTokens.Radius.panel)) .background(if (focused) C.fg else Color.Transparent, RoundedCornerShape(WolfskinTokens.Radius.panel))
.pointerInput(item.id) { .then(if (interactive) Modifier.pointerInput(item.id) {
awaitPointerEventScope { awaitPointerEventScope {
while (true) if (awaitPointerEvent().type == PointerEventType.Enter) onFocus() while (true) if (awaitPointerEvent().type == PointerEventType.Enter) onFocus()
} }
}.clickable(onClick = onClick) } else Modifier).clickable(enabled = interactive, onClick = onClick)
.padding(horizontal = T.rowPaddingX, vertical = T.rowPaddingY).testTag("coming-row-${item.id}"), .padding(horizontal = T.rowPaddingX, vertical = T.rowPaddingY),
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.rowGap)) { verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.rowGap)) {
AsyncImage(model = request, imageLoader = WolfskinImages.loader(context), contentDescription = null, Box(Modifier.size(L.thumbWidth, L.thumbHeight).clip(RoundedCornerShape(WolfskinTokens.Radius.thumb))
modifier = Modifier.size(L.thumbWidth, L.thumbHeight).clip(RoundedCornerShape(WolfskinTokens.Radius.thumb)) .background(if (posterState.value is AsyncImagePainter.State.Success) Color.Transparent else C.surface3)
.background(if (posterReady.value) Color.Transparent else C.surface3), .testTag("coming-thumb-${item.id}"), contentAlignment = Alignment.Center) {
onState = { posterReady.value = it is AsyncImagePainter.State.Success }, AsyncImage(model = request, imageLoader = WolfskinImages.loader(context), contentDescription = null,
contentScale = androidx.compose.ui.layout.ContentScale.Crop) modifier = Modifier.fillMaxSize(), onState = { posterState.value = it },
contentScale = androidx.compose.ui.layout.ContentScale.Crop)
if (request == null || posterState.value is AsyncImagePainter.State.Error) {
Glyph(if (item.mediaType == "movie") "film" else "tv", C.dim, Modifier.size(T.thumbGlyph))
}
}
Column(Modifier.weight(1f)) { Column(Modifier.weight(1f)) {
Text(item.title, Modifier.comingInLineBox(T.titleLineHeight, T.titleBaseline), Text(item.title, Modifier.comingInLineBox(T.titleLineHeight, T.titleBaseline),
style = ComingInTypography.listTitle, color = foreground, maxLines = 1, overflow = TextOverflow.Ellipsis) style = ComingInTypography.listTitle, color = foreground, maxLines = 1, overflow = TextOverflow.Ellipsis)

View file

@ -33,6 +33,8 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import coil3.request.transitionFactory
import coil3.transition.Transition
import org.justinvisser.wolfskin.design.WolfskinTokens.Discover as D import org.justinvisser.wolfskin.design.WolfskinTokens.Discover as D
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
@ -50,13 +52,14 @@ fun DiscoverArtwork(path: String?, modifier: Modifier, poster: Boolean = false,
this *= ColorMatrix().apply { setToScale(D.ambientBrightness, D.ambientBrightness, D.ambientBrightness, 1f) } this *= ColorMatrix().apply { setToScale(D.ambientBrightness, D.ambientBrightness, D.ambientBrightness, 1f) }
}) else null } }) else null }
val context = LocalContext.current val context = LocalContext.current
val request = remember(context, path, poster, dim) { val heroReady = LocalHeroArtReady.current
val request = remember(context, path, poster, dim, heroReady != null) {
val upright = poster || dim val upright = poster || dim
WolfskinImages.request(context, path, WolfskinImages.request(context, path,
if (upright) WolfskinTokens.Image.heroPosterRequestWidth else WolfskinTokens.Image.heroRequestWidth, if (upright) WolfskinTokens.Image.heroPosterRequestWidth else WolfskinTokens.Image.heroRequestWidth,
if (upright) WolfskinTokens.Image.heroPosterRequestHeight else WolfskinTokens.Image.heroRequestHeight, upright) if (upright) WolfskinTokens.Image.heroPosterRequestHeight else WolfskinTokens.Image.heroRequestHeight, upright)
.let { if (heroReady != null) it.newBuilder().transitionFactory(Transition.Factory.NONE).build() else it }
} }
val heroReady = LocalHeroArtReady.current
if (painter != null) { if (painter != null) {
androidx.compose.runtime.SideEffect { heroReady?.invoke() } androidx.compose.runtime.SideEffect { heroReady?.invoke() }
Image(painter, null, modifier, contentScale = ContentScale.Crop, colorFilter = filter) Image(painter, null, modifier, contentScale = ContentScale.Crop, colorFilter = filter)

View file

@ -1,6 +1,8 @@
// SPDX-License-Identifier: GPL-2.0-only // SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design package org.justinvisser.wolfskin.design
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@ -18,12 +20,23 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.dropShadow import androidx.compose.ui.draw.dropShadow
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.graphics.shadow.Shadow import androidx.compose.ui.graphics.shadow.Shadow
import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
@ -37,48 +50,67 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
/** Sheets draw after the shell nav: their scrim consumes pointers and focus stays inside. */ /** Sheets draw after the shell nav: their scrim consumes pointers and focus stays inside. */
@Composable @Composable
fun DiscoverOverlay(state: DiscoverState) { fun DiscoverOverlay(state: DiscoverState) {
val overlay = state.overlay val current = state.overlay
if (overlay != null && !state.shell.pickerVisible) { var retained by remember { mutableStateOf(current) }
Box(Modifier.fillMaxSize().background(C.sheetScrim).clickable(onClick = {}).testTag("discover-overlay")) { SideEffect { if (current != null) retained = current }
val visible = current != null && !state.shell.pickerVisible
val fade = rememberSheetFade(visible)
val overlay = current ?: retained
if (overlay != null && !state.shell.pickerVisible && (visible || fade.value > 0f)) {
Box(Modifier.fillMaxSize().sheetScrim { fade.value }.clickable(enabled = visible, onClick = {})
.pointerInput(visible) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent(PointerEventPass.Initial)
if (!visible) event.changes.forEach { it.consume() }
}
}
}.testTag("discover-overlay")) {
Column(Modifier.align(Alignment.CenterEnd).width(L.sheetWidth).fillMaxSize() Column(Modifier.align(Alignment.CenterEnd).width(L.sheetWidth).fillMaxSize()
.sheetPanelFade { fade.value }
.dropShadow(RectangleShape, Shadow(radius = WolfskinTokens.Lift.sheetBlur, .dropShadow(RectangleShape, Shadow(radius = WolfskinTokens.Lift.sheetBlur,
color = Color.Black.copy(alpha = WolfskinTokens.Lift.sheetOpacity), color = Color.Black.copy(alpha = WolfskinTokens.Lift.sheetOpacity),
offset = DpOffset(WolfskinTokens.Lift.sheetX, WolfskinTokens.Shell.zero))) offset = DpOffset(WolfskinTokens.Lift.sheetX, WolfskinTokens.Shell.zero)))
.background(C.surface).padding(D.sheetPaddingX, D.sheetPaddingY), verticalArrangement = Arrangement.spacedBy(D.sheetGap)) { .background(C.surface).padding(D.sheetPaddingX, D.sheetPaddingY), verticalArrangement = Arrangement.spacedBy(D.sheetGap)) {
when (overlay) { key(overlay) {
is DiscoverOverlayState.Request -> RequestSheet(state, overlay.state) when (overlay) {
is DiscoverOverlayState.Hidden -> { is DiscoverOverlayState.Request -> RequestSheet(state, overlay.state, visible)
ReceiptPanel("Hidden from your picks", "${overlay.title.title} leaves every row of ${state.shell.person.name}’s picks. It waits under Hidden picks at the end of Discover.") is DiscoverOverlayState.Hidden -> {
Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { ReceiptPanel("Hidden from your picks", "${overlay.title.title} leaves every row of ${state.shell.person.name}’s picks. It waits under Hidden picks at the end of Discover.")
DiscoverButton("Undo", overlay.focus == 0) { state.restore(overlay.title, showToast = false) } Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
DiscoverButton("Done", overlay.focus == 1, primary = false) { overlay.focus = 1; state.key(ShellKey.Ok) } DiscoverButton("Undo", overlay.focus == 0, enabled = visible) { state.restore(overlay.title, showToast = false) }
DiscoverButton("Done", overlay.focus == 1, primary = false, enabled = visible) { overlay.focus = 1; state.key(ShellKey.Ok) }
}
} }
} is DiscoverOverlayState.Restore -> {
is DiscoverOverlayState.Restore -> { Text(overlay.title.title, Modifier.discoverLineBox(DiscoverType.sheetTitle), color = C.fg, style = DiscoverType.sheetTitle)
Text(overlay.title.title, Modifier.discoverLineBox(DiscoverType.sheetTitle), color = C.fg, style = DiscoverType.sheetTitle) Text("Hidden from ${state.shell.person.name}’s picks.",
Text("Hidden from ${state.shell.person.name}’s picks.", Modifier.offset(y = D.sheetSubTop).padding(bottom = D.sheetSubGap + D.sheetSubTop).discoverLineBox(DiscoverType.body),
Modifier.offset(y = D.sheetSubTop).padding(bottom = D.sheetSubGap + D.sheetSubTop).discoverLineBox(DiscoverType.body), color = C.muted, style = DiscoverType.body)
color = C.muted, style = DiscoverType.body) Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { DiscoverButton("Show again", overlay.focus == 0, enabled = visible) { state.restore(overlay.title, showToast = true) }
DiscoverButton("Show again", overlay.focus == 0) { state.restore(overlay.title, showToast = true) } DiscoverButton("Back", overlay.focus == 1, primary = false, enabled = visible) { state.overlay = null }
DiscoverButton("Back", overlay.focus == 1, primary = false) { state.overlay = null } }
} }
} }
} }
} }
} }
} }
state.toast?.let { RecoveryToast(it) } RecoveryToastHost(state.toast)
} }
@Composable @Composable
private fun RequestSheet(state: DiscoverState, sheet: RequestSheetState) { private fun RequestSheet(state: DiscoverState, sheet: RequestSheetState, interactive: Boolean) {
var previousPhase by remember(sheet) { mutableStateOf(sheet.phase) }
val phase = sheet.phase
SideEffect { previousPhase = phase }
Text(if (sheet.held) "Add for someone" else "Request", Modifier.exactLineBox(DiscoverType.sheetTitle), color = C.fg, style = DiscoverType.sheetTitle) Text(if (sheet.held) "Add for someone" else "Request", Modifier.exactLineBox(DiscoverType.sheetTitle), color = C.fg, style = DiscoverType.sheetTitle)
Text("${sheet.title.title} · ${sheet.title.typeWord}${sheet.title.year?.let { " · $it" }.orEmpty()}", Text("${sheet.title.title} · ${sheet.title.typeWord}${sheet.title.year?.let { " · $it" }.orEmpty()}",
Modifier.exactLineBox(DiscoverType.body, top = D.sheetSubTop, bottom = D.sheetSubGap), color = C.muted, style = DiscoverType.body) Modifier.exactLineBox(DiscoverType.body, top = D.sheetSubTop, bottom = D.sheetSubGap), color = C.muted, style = DiscoverType.body)
if (state.isSignedIn) SheetRatings(sheet.title.ratings) if (state.isSignedIn) SheetRatings(sheet.title.ratings)
when (sheet.phase) { when (phase) {
RequestPhase.Choose, RequestPhase.Sending -> RequestChoices(state, sheet) RequestPhase.Choose, RequestPhase.Sending -> RequestChoices(state, sheet, interactive)
RequestPhase.LoadingRecipients -> { RequestPhase.LoadingRecipients -> {
Text("For", Modifier.padding(top = D.sheetLabelTop), color = C.dim, style = DiscoverType.label) Text("For", Modifier.padding(top = D.sheetLabelTop), color = C.dim, style = DiscoverType.label)
repeat(3) { repeat(3) {
@ -89,10 +121,21 @@ private fun RequestSheet(state: DiscoverState, sheet: RequestSheetState) {
} }
} }
Box(Modifier.padding(top = D.sheetButtonTop)) { Box(Modifier.padding(top = D.sheetButtonTop)) {
DiscoverButton("Close", true, primary = false) { state.overlay = null } DiscoverButton("Close", true, primary = false, enabled = interactive) { state.overlay = null }
} }
} }
else -> RequestOutcome(state, sheet) else -> {
val opacity = remember(sheet, phase) { Animatable(if (previousPhase == RequestPhase.Sending) 0f else 1f) }
val reduced = reducedMotion()
LaunchedEffect(opacity, reduced) {
if (opacity.value < 1f) opacity.animateTo(1f,
tween(motionDuration(WolfskinTokens.Motion.lateLineFade, reduced), easing = WolfskinTokens.Motion.ease))
}
Column(Modifier.fillMaxWidth().graphicsLayer {
alpha = opacity.value
compositingStrategy = CompositingStrategy.ModulateAlpha
}, verticalArrangement = Arrangement.spacedBy(D.sheetGap)) { RequestOutcome(state, sheet, interactive) }
}
} }
} }
@ -119,7 +162,7 @@ private fun SheetRatings(ratings: List<DiscoverRating>) {
} }
@Composable @Composable
private fun RequestChoices(state: DiscoverState, sheet: RequestSheetState) { private fun RequestChoices(state: DiscoverState, sheet: RequestSheetState, interactive: Boolean) {
Text("For", Modifier.padding(top = D.sheetLabelTop), color = C.dim, style = DiscoverType.label) Text("For", Modifier.padding(top = D.sheetLabelTop), color = C.dim, style = DiscoverType.label)
sheet.people.forEachIndexed { index, person -> sheet.people.forEachIndexed { index, person ->
val focused = sheet.focus == index val focused = sheet.focus == index
@ -127,7 +170,7 @@ private fun RequestChoices(state: DiscoverState, sheet: RequestSheetState) {
Row(Modifier.fillMaxWidth().height(L.sheetRow).alpha(if (disabled) .5f else 1f) Row(Modifier.fillMaxWidth().height(L.sheetRow).alpha(if (disabled) .5f else 1f)
.focusScale(focused, WolfskinTokens.Focus.scaleRow) .focusScale(focused, WolfskinTokens.Focus.scaleRow)
.background(if (focused) C.fg else C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow)) .background(if (focused) C.fg else C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow))
.clickable(enabled = !disabled && sheet.phase == RequestPhase.Choose) { sheet.selected = index; sheet.focus = sheet.buttonIndex } .clickable(enabled = interactive && !disabled && sheet.phase == RequestPhase.Choose) { sheet.selected = index; sheet.focus = sheet.buttonIndex }
.padding(horizontal = D.sheetRowPadding), horizontalArrangement = Arrangement.spacedBy(D.sheetPersonGap), verticalAlignment = Alignment.CenterVertically) { .padding(horizontal = D.sheetRowPadding), horizontalArrangement = Arrangement.spacedBy(D.sheetPersonGap), verticalAlignment = Alignment.CenterVertically) {
Avatar(person, D.sheetAvatar, WolfskinTokens.Type.avatarLetterSheetRow) Avatar(person, D.sheetAvatar, WolfskinTokens.Type.avatarLetterSheetRow)
Text(person.name + if (person.key == sheet.viewer.key) " (you)" else "", Modifier.weight(1f), color = if (focused) C.onFocus else C.fg, style = DiscoverType.listTitle) Text(person.name + if (person.key == sheet.viewer.key) " (you)" else "", Modifier.weight(1f), color = if (focused) C.onFocus else C.fg, style = DiscoverType.listTitle)
@ -141,12 +184,16 @@ private fun RequestChoices(state: DiscoverState, sheet: RequestSheetState) {
if (sheet.blocked) Text("Season details are unknown right now, so this series can’t be requested yet. Nothing was sent.", color = C.warn, style = DiscoverType.caption) if (sheet.blocked) Text("Season details are unknown right now, so this series can’t be requested yet. Nothing was sent.", color = C.warn, style = DiscoverType.caption)
else if (sheet.title.type == "tv" && !sheet.held) Text("Whole series: every listed season. Other seasons may download too.", color = C.dim, style = DiscoverType.caption) else if (sheet.title.type == "tv" && !sheet.held) Text("Whole series: every listed season. Other seasons may download too.", color = C.dim, style = DiscoverType.caption)
Box(Modifier.padding(top = D.sheetButtonTop)) { Box(Modifier.padding(top = D.sheetButtonTop)) {
DiscoverButton(if (sheet.blocked) "Close" else sheet.buttonLabel, sheet.focus == sheet.buttonIndex, glyph = if (sheet.blocked) null else "plus", primary = !sheet.blocked) { sheet.focus = sheet.buttonIndex; state.key(ShellKey.Ok) } DiscoverButton(if (sheet.blocked) "Close" else sheet.buttonLabel, sheet.focus == sheet.buttonIndex,
glyph = if (sheet.blocked) null else "plus", primary = !sheet.blocked, enabled = interactive,
busy = sheet.phase == RequestPhase.Sending, busyLabel = if (sheet.blocked) null else "Sending…") {
sheet.focus = sheet.buttonIndex; state.key(ShellKey.Ok)
}
} }
} }
@Composable @Composable
private fun RequestOutcome(state: DiscoverState, sheet: RequestSheetState) { private fun RequestOutcome(state: DiscoverState, sheet: RequestSheetState, interactive: Boolean) {
val phase = sheet.phase val phase = sheet.phase
val name = sheet.selectedPerson.name val name = sheet.selectedPerson.name
val actor = sheet.result?.actor val actor = sheet.result?.actor
@ -169,9 +216,9 @@ private fun RequestOutcome(state: DiscoverState, sheet: RequestSheetState) {
if (phase == RequestPhase.Receipt) Text("Follow it under Requests in Settings.", color = C.dim, style = DiscoverType.caption) if (phase == RequestPhase.Receipt) Text("Follow it under Requests in Settings.", color = C.dim, style = DiscoverType.caption)
Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
if (phase in setOf(RequestPhase.NotConfirmed, RequestPhase.Pending, RequestPhase.RecipientsUnavailable)) { if (phase in setOf(RequestPhase.NotConfirmed, RequestPhase.Pending, RequestPhase.RecipientsUnavailable)) {
DiscoverButton(if (phase == RequestPhase.RecipientsUnavailable) "Try again" else "Check again", sheet.focus == sheet.buttonIndex) { sheet.focus = sheet.buttonIndex; state.key(ShellKey.Ok) } DiscoverButton(if (phase == RequestPhase.RecipientsUnavailable) "Try again" else "Check again", sheet.focus == sheet.buttonIndex, enabled = interactive) { sheet.focus = sheet.buttonIndex; state.key(ShellKey.Ok) }
DiscoverButton("Close", sheet.focus != sheet.buttonIndex, primary = false) { state.overlay = null } DiscoverButton("Close", sheet.focus != sheet.buttonIndex, primary = false, enabled = interactive) { state.overlay = null }
} else DiscoverButton(if (phase == RequestPhase.Receipt) "Done" else "Close", true) { state.overlay = null } } else DiscoverButton(if (phase == RequestPhase.Receipt) "Done" else "Close", true, enabled = interactive) { state.overlay = null }
} }
} }

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