Fix TV startup navigation and complete title pages
This commit is contained in:
parent
e794dec253
commit
d401ebc355
13 changed files with 383 additions and 26 deletions
|
|
@ -63,7 +63,6 @@ class DiscoverLoadingFocusTest {
|
||||||
rule.runOnIdle {
|
rule.runOnIdle {
|
||||||
assertNotNull(current.title)
|
assertNotNull(current.title)
|
||||||
assertEquals("The Amazing Spider-Man", current.title!!.title)
|
assertEquals("The Amazing Spider-Man", current.title!!.title)
|
||||||
assertEquals("discover-title/${current.title!!.key}", shell.route)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.test.*
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.github.damontecres.wholphin.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 java.io.File
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.mockk
|
||||||
|
import java.util.concurrent.CountDownLatch
|
||||||
|
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.annotation.Config
|
||||||
|
import org.robolectric.annotation.GraphicsMode
|
||||||
|
|
||||||
|
/** Catalogue details must reach the same late-filling cast/person/related surface as held titles. */
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
class DiscoverTitleTimingTest {
|
||||||
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
@Test fun discoverDetailsFillCastAndRelatedAfterAsyncReplyAndBackRestoresDiscover() {
|
||||||
|
FakeConductarr().use { fake ->
|
||||||
|
fake.localArt = true
|
||||||
|
ConductarrSession(MemoryCacheStore()).use { session ->
|
||||||
|
session.select("server:shared", SHARED_TOKEN, fake.url)
|
||||||
|
runBlocking { session.authenticate() }
|
||||||
|
val identity = session.identity.value!!
|
||||||
|
val facade = mockk<TitleReadFacade>()
|
||||||
|
coEvery { facade.findHeld(any(), any()) } returns null
|
||||||
|
val navigation = NavigationManager().apply { replace(Destination.Wolfskin("discover")) }
|
||||||
|
val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), navigation, session, mockk(relaxed = true))
|
||||||
|
val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", false)), "shared").apply {
|
||||||
|
pickerVisible = false; pickerLaunch = false; enterDestination("discover")
|
||||||
|
}
|
||||||
|
lateinit var discover: DiscoverState
|
||||||
|
val release = CountDownLatch(1)
|
||||||
|
try {
|
||||||
|
rule.setContent {
|
||||||
|
val route = (navigation.backStack.last() as Destination.Wolfskin).route
|
||||||
|
LaunchedEffect(route) { shell.enterDestination(route) }
|
||||||
|
discover = rememberDiscoverState(shell, { navigation.navigateTo(Destination.Wolfskin(it)) }, navigation::goBack, identity = identity, fixtures = false)
|
||||||
|
WolfskinTheme {
|
||||||
|
if (route.startsWith("title/")) TitleDestination(route, shell, UserPreferences(AppPreferences.getDefaultInstance(), null), viewModel = model, fixtures = false)
|
||||||
|
else ShellSurface(shell) { DiscoverScreen(discover, it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rule.waitUntil(30_000) { discover.snapshot.status == DiscoverStatus.Ready }
|
||||||
|
rule.runOnIdle {
|
||||||
|
fake.beforeResponse = { if (it.endsWith("/cast")) release.await() }
|
||||||
|
discover.openTitle(DiscoverTitle(44, "tv", "Black Lightning"))
|
||||||
|
}
|
||||||
|
rule.waitForIdle()
|
||||||
|
assertTrue("Discover details must open the shared title destination, not its header-only preview", (navigation.backStack.last() as Destination.Wolfskin).route.startsWith("title/"))
|
||||||
|
rule.waitUntil(10_000) { fake.calls.any { it.url.encodedPath == "/catalogue/titles/44/tv/cast" } }
|
||||||
|
rule.runOnIdle { release.countDown() }
|
||||||
|
rule.waitUntil(30_000) {
|
||||||
|
org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
|
||||||
|
model.title?.data?.cast?.isNotEmpty() == true && model.title?.data?.related?.isNotEmpty() == true
|
||||||
|
}
|
||||||
|
rule.onRoot().performKeyInput { pressKey(Key.DirectionDown) }
|
||||||
|
assertEquals("cast", model.title!!.row)
|
||||||
|
rule.onNodeWithText("Sam Rockwell").assertIsDisplayed()
|
||||||
|
val captures = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "walk-fixes").apply { mkdirs() }
|
||||||
|
rule.onRoot().captureRoboImage(File(captures, "discover-title-cast.png").absolutePath)
|
||||||
|
rule.onRoot().performKeyInput { pressKey(Key.DirectionDown) }
|
||||||
|
assertEquals("related", model.title!!.row)
|
||||||
|
rule.onNodeWithText("More like this").assertIsDisplayed()
|
||||||
|
rule.onRoot().captureRoboImage(File(captures, "discover-title-related.png").absolutePath)
|
||||||
|
rule.onRoot().performKeyInput { pressKey(Key.Back) }
|
||||||
|
rule.waitForIdle()
|
||||||
|
assertNull(discover.title)
|
||||||
|
assertEquals("discover", shell.route)
|
||||||
|
} finally {
|
||||||
|
release.countDown()
|
||||||
|
model.viewModelScope.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -26,6 +26,7 @@ internal class FakeConductarr : Closeable {
|
||||||
@Volatile var emptyAudiences = false
|
@Volatile var emptyAudiences = false
|
||||||
@Volatile var seasonTwoScore = 8.1
|
@Volatile var seasonTwoScore = 8.1
|
||||||
@Volatile var localArt = false
|
@Volatile var localArt = false
|
||||||
|
@Volatile var beforeResponse: ((String) -> Unit)? = null
|
||||||
private val imageBytes = java.util.Base64.getDecoder().decode(
|
private val imageBytes = java.util.Base64.getDecoder().decode(
|
||||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jYioAAAAASUVORK5CYII=")
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jYioAAAAASUVORK5CYII=")
|
||||||
|
|
||||||
|
|
@ -37,6 +38,7 @@ internal class FakeConductarr : Closeable {
|
||||||
if (localArt && request.url.encodedPath == "/image") return MockResponse.Builder()
|
if (localArt && request.url.encodedPath == "/image") return MockResponse.Builder()
|
||||||
.body(okio.Buffer().write(imageBytes)).build()
|
.body(okio.Buffer().write(imageBytes)).build()
|
||||||
val path = request.url.encodedPath
|
val path = request.url.encodedPath
|
||||||
|
beforeResponse?.invoke(path)
|
||||||
if (request.method == "POST" && path == "/api/v1/requests") return response(wire.getValue("receipt").toString(), postStatus)
|
if (request.method == "POST" && path == "/api/v1/requests") return response(wire.getValue("receipt").toString(), postStatus)
|
||||||
if (request.method == "POST") return response(wire.getValue("hide").toString())
|
if (request.method == "POST") return response(wire.getValue("hide").toString())
|
||||||
if (request.method == "DELETE") return response(wire.getValue("restore").toString())
|
if (request.method == "DELETE") return response(wire.getValue("restore").toString())
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.toPixelMap
|
||||||
|
import androidx.compose.ui.test.captureToImage
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onRoot
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
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
|
||||||
|
|
||||||
|
/** Scrolled cards must be occluded across the entire persistent navigation band. */
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
class NavBandOcclusionTest {
|
||||||
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
@Test fun scrolledRowsNeverShowThroughTheNavBand() {
|
||||||
|
val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", false)), "shared").apply {
|
||||||
|
pickerVisible = false; pickerLaunch = false; enterDestination("discover")
|
||||||
|
}
|
||||||
|
rule.setContent { WolfskinTheme { ShellSurface(shell) { modifier ->
|
||||||
|
LazyColumn(state = shell.view.listState, modifier = modifier.fillMaxSize()) {
|
||||||
|
items(20) { Box(Modifier.fillMaxSize().height(200.dp).background(Color.Red)) }
|
||||||
|
}
|
||||||
|
} } }
|
||||||
|
rule.runOnIdle { runBlocking { shell.view.listState.scrollToItem(2) } }
|
||||||
|
rule.waitForIdle()
|
||||||
|
val pixels = rule.onRoot().captureToImage().toPixelMap()
|
||||||
|
val background = WolfskinTokens.Color.bg
|
||||||
|
for (y in listOf(5, 50, 135)) {
|
||||||
|
val actual = pixels[20, y]
|
||||||
|
assertEquals("Nav red at y=$y", background.red, actual.red, 1f / 255f)
|
||||||
|
assertEquals("Nav green at y=$y", background.green, actual.green, 1f / 255f)
|
||||||
|
assertEquals("Nav blue at y=$y", background.blue, actual.blue, 1f / 255f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,163 @@
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
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.ui.nav.Destination
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.mockk
|
||||||
|
import io.mockk.verify
|
||||||
|
import java.util.UUID
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.coroutines.test.resetMain
|
||||||
|
import kotlinx.coroutines.flow.last
|
||||||
|
import kotlinx.coroutines.test.setMain
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
|
||||||
|
import org.justinvisser.wolfskin.design.*
|
||||||
|
import org.justinvisser.wolfskin.tv.read.LibraryReadFacade
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
/** Saved-login I/O cannot leave an interactive but disconnected launch shell. */
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class)
|
||||||
|
class ShellStartupTimingTest {
|
||||||
|
@Before fun setMain() { Dispatchers.setMain(Dispatchers.Unconfined) }
|
||||||
|
@After fun resetMain() { Dispatchers.resetMain() }
|
||||||
|
|
||||||
|
@Test fun firstFramePickerAndEveryTabWorkBeforeSavedPeopleArrive() = runBlocking {
|
||||||
|
val context = ApplicationProvider.getApplicationContext<Application>()
|
||||||
|
ConductarrPreference.initialize(context)
|
||||||
|
ConductarrPreference.updateFixtures(false)
|
||||||
|
val previous = ConductarrPreference.baseUrl
|
||||||
|
FakeConductarr().use { fake ->
|
||||||
|
ConductarrPreference.updateBaseUrl(fake.url)
|
||||||
|
ConductarrSession(MemoryCacheStore()).use { conductarr ->
|
||||||
|
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null)
|
||||||
|
val user = JellyfinUser(id = UUID.randomUUID(), name = "Shared", serverId = server.id, accessToken = SHARED_TOKEN)
|
||||||
|
conductarr.select("${server.id}:${user.id}", SHARED_TOKEN, fake.url)
|
||||||
|
conductarr.authenticate()
|
||||||
|
val activityEntered = CompletableDeferred<Unit>()
|
||||||
|
val releaseActivity = java.util.concurrent.CountDownLatch(1)
|
||||||
|
fake.beforeResponse = {
|
||||||
|
if (it == "/api/v1/activity") {
|
||||||
|
activityEntered.complete(Unit)
|
||||||
|
releaseActivity.await()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val activity = async(Dispatchers.IO) { conductarr.identity.value!!.provider.requests().last() }
|
||||||
|
withTimeout(5_000) { activityEntered.await() }
|
||||||
|
val savedPeople = CompletableDeferred<Unit>()
|
||||||
|
val repository = mockk<ServerRepository>(relaxed = true)
|
||||||
|
coEvery { repository.serverDao.getServer(server.id) } coAnswers { savedPeople.await(); null }
|
||||||
|
val navigation = mockk<NavigationManager>(relaxed = true)
|
||||||
|
val libraries = mockk<LibraryReadFacade>(relaxed = true)
|
||||||
|
coEvery { libraries.readDestinations(user.id) } returns emptyMap()
|
||||||
|
val session = WolfskinSession()
|
||||||
|
val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(), mockk(relaxed = true),
|
||||||
|
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
|
||||||
|
val load = launch { model.load(server, user) }
|
||||||
|
try {
|
||||||
|
yield()
|
||||||
|
val shell = requireNotNull(session.state) { "The current Jellyfin person must paint before saved-login I/O" }
|
||||||
|
assertEquals(PeopleStatus.Ready, shell.peopleStatus)
|
||||||
|
assertEquals(user.id.toString(), shell.person.jellyfinUserId)
|
||||||
|
shell.key(ShellKey.Ok)
|
||||||
|
for (route in listOf("movies", "shows", "discover")) {
|
||||||
|
shell.focusNav(shell.tabs.indexOf(route))
|
||||||
|
shell.key(ShellKey.Ok)
|
||||||
|
verify { navigation.replace(Destination.Wolfskin(route)) }
|
||||||
|
assertEquals(route, shell.route)
|
||||||
|
}
|
||||||
|
savedPeople.complete(Unit)
|
||||||
|
load.join()
|
||||||
|
assertSame(shell, session.state)
|
||||||
|
assertEquals("discover", shell.route)
|
||||||
|
assertFalse("Activity must still be pending while the picker and tabs are usable", activity.isCompleted)
|
||||||
|
} finally {
|
||||||
|
releaseActivity.countDown()
|
||||||
|
activity.cancelAndJoin()
|
||||||
|
savedPeople.complete(Unit)
|
||||||
|
load.cancelAndJoin()
|
||||||
|
model.viewModelScope.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ConductarrPreference.updateBaseUrl(previous)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun switchingPersonDuringHouseholdReadKeepsTheNewIdentityAndReturnsHome() = runBlocking {
|
||||||
|
val context = ApplicationProvider.getApplicationContext<Application>()
|
||||||
|
ConductarrPreference.initialize(context)
|
||||||
|
ConductarrPreference.updateFixtures(false)
|
||||||
|
val previous = ConductarrPreference.baseUrl
|
||||||
|
FakeConductarr().use { fake ->
|
||||||
|
ConductarrPreference.updateBaseUrl(fake.url)
|
||||||
|
ConductarrSession(MemoryCacheStore()).use { conductarr ->
|
||||||
|
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null)
|
||||||
|
val shared = JellyfinUser(id = UUID.randomUUID(), name = "Shared", serverId = server.id, accessToken = SHARED_TOKEN)
|
||||||
|
val mindel = shared.copy(id = UUID.randomUUID(), name = "Mindel", accessToken = MINDEL_TOKEN)
|
||||||
|
val repository = mockk<ServerRepository>(relaxed = true)
|
||||||
|
coEvery { repository.serverDao.getServer(server.id) } returns
|
||||||
|
com.github.damontecres.wholphin.data.model.JellyfinServerUsers(server, listOf(shared, mindel))
|
||||||
|
val switched = CompletableDeferred<Unit>()
|
||||||
|
coEvery { repository.changeUser(server, mindel) } coAnswers {
|
||||||
|
switched.complete(Unit)
|
||||||
|
com.github.damontecres.wholphin.data.CurrentUser(server, mindel)
|
||||||
|
}
|
||||||
|
val navigation = mockk<NavigationManager>(relaxed = true)
|
||||||
|
val libraries = mockk<LibraryReadFacade>(relaxed = true)
|
||||||
|
coEvery { libraries.readDestinations(any()) } returns emptyMap()
|
||||||
|
val session = WolfskinSession()
|
||||||
|
val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(relaxed = true), mockk(relaxed = true),
|
||||||
|
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
|
||||||
|
val pending = CompletableDeferred<Unit>()
|
||||||
|
val release = java.util.concurrent.CountDownLatch(1)
|
||||||
|
val firstAudience = java.util.concurrent.atomic.AtomicBoolean(true)
|
||||||
|
fake.beforeResponse = {
|
||||||
|
if (it == "/curation/library-audiences" && firstAudience.getAndSet(false)) {
|
||||||
|
pending.complete(Unit)
|
||||||
|
release.await()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
model.load(server, shared)
|
||||||
|
withTimeout(5_000) { pending.await() }
|
||||||
|
val shell = requireNotNull(session.state)
|
||||||
|
shell.key(ShellKey.Ok)
|
||||||
|
shell.enterDestination("discover")
|
||||||
|
shell.focusNav(shell.tabs.size)
|
||||||
|
shell.key(ShellKey.Ok)
|
||||||
|
shell.focusPerson(1)
|
||||||
|
shell.key(ShellKey.Ok)
|
||||||
|
assertEquals("home", shell.route)
|
||||||
|
verify { navigation.replace(Destination.Home()) }
|
||||||
|
withTimeout(5_000) { switched.await() }
|
||||||
|
model.load(server, mindel)
|
||||||
|
requireNotNull(model.onForeground(server, mindel)).join()
|
||||||
|
release.countDown()
|
||||||
|
assertSame(shell, session.state)
|
||||||
|
assertEquals("mindel", model.signedInPerson)
|
||||||
|
assertEquals(mindel.id.toString(), shell.person.jellyfinUserId)
|
||||||
|
assertEquals("Mindel", shell.person.name)
|
||||||
|
assertEquals("mindel", conductarr.identity.value!!.operator!!.requester)
|
||||||
|
assertEquals("home", shell.route)
|
||||||
|
} finally {
|
||||||
|
release.countDown()
|
||||||
|
model.viewModelScope.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ConductarrPreference.updateBaseUrl(previous)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -81,6 +81,7 @@ class TitleReadFacadeTest {
|
||||||
every { repository.currentServer } returns null
|
every { repository.currentServer } returns null
|
||||||
every { repository.currentUser } returns null
|
every { repository.currentUser } returns null
|
||||||
coEvery { playback.getSelectedTracks(any(), any(), any()) } returns null
|
coEvery { playback.getSelectedTracks(any(), any(), any()) } returns null
|
||||||
|
coEvery { items.getItems(any<GetItemsRequest>()) } returns successQueryResult(emptyList())
|
||||||
val context = mockk<Context> { every { filesDir } returns directory.root }
|
val context = mockk<Context> { every { filesDir } returns directory.root }
|
||||||
facade = TitleReadFacade(api, mockk<ImageUrlService>(relaxed = true), playback, repository, mockk<FavoriteWatchManager>(), context)
|
facade = TitleReadFacade(api, mockk<ImageUrlService>(relaxed = true), playback, repository, mockk<FavoriteWatchManager>(), context)
|
||||||
}
|
}
|
||||||
|
|
@ -103,6 +104,29 @@ class TitleReadFacadeTest {
|
||||||
mediaSources = listOf(source.copy(id = sourceId, name = "Original", container = "mkv", mediaStreams = streams)))
|
mediaSources = listOf(source.copy(id = sourceId, name = "Original", container = "mkv", mediaStreams = streams)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test fun heldMovieResolvesCatalogueIdentityWhenTheDetailOmitsProviderIds() = runTest {
|
||||||
|
coEvery { library.getItem(firstId) } returns successResponse(file(firstId))
|
||||||
|
coEvery { items.getItems(any<GetItemsRequest>()) } returns successQueryResult(
|
||||||
|
listOf(file(firstId).copy(providerIds = mapOf("Tmdb" to "42"))))
|
||||||
|
val page = facade.read(firstId, null, preferences)
|
||||||
|
assertEquals(42L, page.item.tmdbId)
|
||||||
|
assertEquals(firstId.toString(), page.watchTarget.id)
|
||||||
|
assertEquals(firstId.toString(), TitleState(page, ShellPerson("shared", "Shared", "S", false)) {}.target?.id)
|
||||||
|
FakeConductarr().use { fake ->
|
||||||
|
ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()).use { session ->
|
||||||
|
session.select("server:shared", SHARED_TOKEN, fake.url)
|
||||||
|
session.authenticate()
|
||||||
|
val screen = TitleState(page, ShellPerson("shared", "Shared", "S", false)) {}
|
||||||
|
ConductarrTitlePages(session.identity.value!!).title(requireNotNull(page.item.tmdbId),
|
||||||
|
org.justinvisser.wolfskin.conductarr.MediaType.MOVIE, page).collect { screen.update(it) }
|
||||||
|
assertEquals(listOf("header", "cast", "related"), screen.rows)
|
||||||
|
assertEquals("Sam Rockwell", screen.data.cast.single().name)
|
||||||
|
assertEquals(firstId.toString(), screen.target?.id)
|
||||||
|
assertTrue(screen.data.item.held)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test fun diskCopyPreservesFileAndNonContiguousStreamIndexesWithoutRatingsBeforeSignIn() = runTest {
|
@Test fun diskCopyPreservesFileAndNonContiguousStreamIndexesWithoutRatingsBeforeSignIn() = runTest {
|
||||||
coEvery { library.getItem(firstId) } returns successResponse(file(firstId))
|
coEvery { library.getItem(firstId) } returns successResponse(file(firstId))
|
||||||
facade.read(firstId, null, preferences)
|
facade.read(firstId, null, preferences)
|
||||||
|
|
|
||||||
|
|
@ -95,18 +95,6 @@ internal fun rememberDiscoverState(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LaunchedEffect(state.title?.key, state) {
|
|
||||||
val title = state.title ?: return@LaunchedEffect
|
|
||||||
if (!fixtures && identity?.signedIn == true) {
|
|
||||||
val media = if (title.type == "movie") MediaType.MOVIE else MediaType.TV
|
|
||||||
identity.provider.detail(title.tmdb, media).collect { result ->
|
|
||||||
val detail = when (result) { is ReadResult.Value -> result.saved.value; is ReadResult.Failure -> result.saved?.value }
|
|
||||||
if (detail != null && state.title?.key == title.key) state.updateTitle(title.copy(
|
|
||||||
runtimeSeconds = detail.runtime?.times(60)?.toInt(), genres = detail.genres.orEmpty(),
|
|
||||||
seasons = detail.seasons?.map { it.number.toInt() }, seasonCount = detail.seasons?.size, heldBy = identity.provider.holders(detail.title)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val result = if (fixtures) fixtureState ?: state else state
|
val result = if (fixtures) fixtureState ?: state else state
|
||||||
LaunchedEffect(shell.route, result) {
|
LaunchedEffect(shell.route, result) {
|
||||||
result.enterRoute(shell.route)
|
result.enterRoute(shell.route)
|
||||||
|
|
|
||||||
|
|
@ -95,14 +95,14 @@ class WolfskinShellViewModel @Inject constructor(
|
||||||
if (ConductarrPreference.fixtures) conductarr.close()
|
if (ConductarrPreference.fixtures) conductarr.close()
|
||||||
else conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl)
|
else conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl)
|
||||||
signedInPerson = session.state?.people?.firstOrNull { it.jellyfinUserId == user.id.toString() }?.key ?: user.name.orEmpty().lowercase()
|
signedInPerson = session.state?.people?.firstOrNull { it.jellyfinUserId == user.id.toString() }?.key ?: user.name.orEmpty().lowercase()
|
||||||
|
session.onAction = { action -> handleAction(action, server, user) }
|
||||||
if (session.state == null || session.serverId != server.id.toString() || fixtureMode != ConductarrPreference.fixtures) {
|
if (session.state == null || session.serverId != server.id.toString() || fixtureMode != ConductarrPreference.fixtures) {
|
||||||
val saved = withContext(Dispatchers.IO) { repository.serverDao.getServer(server.id)?.users.orEmpty() }
|
|
||||||
val people = readShellPeople(context, saved.ifEmpty { listOf(user) })
|
|
||||||
session.serverId = server.id.toString()
|
session.serverId = server.id.toString()
|
||||||
fixtureMode = ConductarrPreference.fixtures
|
fixtureMode = ConductarrPreference.fixtures
|
||||||
session.state = ShellState(people, signedInPerson) { session.onAction(it) }
|
session.state = ShellState(readShellPeople(context, listOf(user)), signedInPerson) { session.onAction(it) }
|
||||||
|
val saved = withContext(Dispatchers.IO) { repository.serverDao.getServer(server.id)?.users.orEmpty() }
|
||||||
|
session.state?.updatePeople(readShellPeople(context, saved.ifEmpty { listOf(user) }))
|
||||||
}
|
}
|
||||||
session.onAction = { action -> handleAction(action, server, user) }
|
|
||||||
identityJob = viewModelScope.launch {
|
identityJob = viewModelScope.launch {
|
||||||
if (!ConductarrPreference.fixtures) loadHousehold(user)
|
if (!ConductarrPreference.fixtures) loadHousehold(user)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -83,8 +83,8 @@ class TitleReadFacade @Inject constructor(
|
||||||
/** An episode opens its series with that exact episode as both play and watched target. */
|
/** An episode opens its series with that exact episode as both play and watched target. */
|
||||||
suspend fun read(id: UUID, season: Int?, preferences: UserPreferences): TitlePageData = withContext(Dispatchers.IO) {
|
suspend fun read(id: UUID, season: Int?, preferences: UserPreferences): TitlePageData = withContext(Dispatchers.IO) {
|
||||||
val file = cacheFile(id)
|
val file = cacheFile(id)
|
||||||
val opened = api.userLibraryApi.getItem(id).content
|
val opened = readItem(id)
|
||||||
val series = if (opened.type == BaseItemKind.EPISODE && opened.seriesId != null) api.userLibraryApi.getItem(opened.seriesId!!).content else opened
|
val series = if (opened.type == BaseItemKind.EPISODE && opened.seriesId != null) readItem(opened.seriesId!!) else opened
|
||||||
val seasons = if (series.type == BaseItemKind.SERIES) api.itemsApi.getItems(GetItemsRequest(parentId = series.id, includeItemTypes = listOf(BaseItemKind.SEASON), recursive = false, sortBy = listOf(ItemSortBy.INDEX_NUMBER))).content.items.orEmpty() else emptyList()
|
val seasons = if (series.type == BaseItemKind.SERIES) api.itemsApi.getItems(GetItemsRequest(parentId = series.id, includeItemTypes = listOf(BaseItemKind.SEASON), recursive = false, sortBy = listOf(ItemSortBy.INDEX_NUMBER))).content.items.orEmpty() else emptyList()
|
||||||
val next = if (opened.type == BaseItemKind.EPISODE || opened.type == BaseItemKind.MOVIE) opened else {
|
val next = if (opened.type == BaseItemKind.EPISODE || opened.type == BaseItemKind.MOVIE) opened else {
|
||||||
api.tvShowsApi.getNextUp(GetNextUpRequest(seriesId = series.id, userId = repository.currentUser?.id, limit = 1, fields = fields, enableResumable = true)).content.items.firstOrNull()?.let { api.userLibraryApi.getItem(it.id).content }
|
api.tvShowsApi.getNextUp(GetNextUpRequest(seriesId = series.id, userId = repository.currentUser?.id, limit = 1, fields = fields, enableResumable = true)).content.items.firstOrNull()?.let { api.userLibraryApi.getItem(it.id).content }
|
||||||
|
|
@ -107,6 +107,20 @@ class TitleReadFacade @Inject constructor(
|
||||||
result.copy(trailerUrl = trailer)
|
result.copy(trailerUrl = trailer)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** User-library detail may omit metadata fields; request catalogue identity and cast explicitly. */
|
||||||
|
private suspend fun readItem(id: UUID): BaseItemDto {
|
||||||
|
val item = api.userLibraryApi.getItem(id).content
|
||||||
|
if (item.providerIds.orEmpty().keys.any { it.equals("Tmdb", true) } && item.people != null) return item
|
||||||
|
val metadata = try {
|
||||||
|
api.itemsApi.getItems(GetItemsRequest(ids = listOf(id), userId = repository.currentUser?.id,
|
||||||
|
fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.PEOPLE))).content.items.orEmpty().firstOrNull { it.id == id }
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
if (exception is kotlinx.coroutines.CancellationException) throw exception
|
||||||
|
null
|
||||||
|
} ?: return item
|
||||||
|
return item.copy(providerIds = metadata.providerIds ?: item.providerIds, people = item.people ?: metadata.people)
|
||||||
|
}
|
||||||
|
|
||||||
/** A failed reachability read leaves navigation on the title instead of opening a dead player. */
|
/** A failed reachability read leaves navigation on the title instead of opening a dead player. */
|
||||||
suspend fun preparePlayback(id: UUID) { api.userLibraryApi.getItem(id) }
|
suspend fun preparePlayback(id: UUID) { api.userLibraryApi.getItem(id) }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -473,6 +473,15 @@ Request recipients include authorized Conductarr profiles without saved Jellyfin
|
||||||
logins; that does not create selectable TV logins. Late name reads preserve the
|
logins; that does not create selectable TV logins. Late name reads preserve the
|
||||||
open chooser's selected recipient and footer focus.
|
open chooser's selected recipient and footer focus.
|
||||||
|
|
||||||
|
The launch picker publishes the current Jellyfin user and working navigation before
|
||||||
|
the saved-login database read. Saved people and Conductarr household metadata fill
|
||||||
|
that same shell later; slow activity or catalogue reads cannot hold its first usable
|
||||||
|
frame. Discover details navigate to the shared title page, including cast, performer
|
||||||
|
navigation and related titles, rather than the header-only design preview. Held
|
||||||
|
items whose detail omits ProviderIds or People get an explicit Jellyfin field read
|
||||||
|
before catalogue enrichment, without changing their playback identity. Scrolled
|
||||||
|
navigation draws an opaque band; the unscrolled hero treatment is unchanged.
|
||||||
|
|
||||||
Artwork uses a fresh bounded Wolfskin loader, not the upstream loader's
|
Artwork uses a fresh bounded Wolfskin loader, not the upstream loader's
|
||||||
authenticated fetcher. The app's network interceptor removes Authorization from
|
authenticated fetcher. The app's network interceptor removes Authorization from
|
||||||
every non-Jellyfin artwork origin (scheme, host and port), including redirects;
|
every non-Jellyfin artwork origin (scheme, host and port), including redirects;
|
||||||
|
|
@ -504,6 +513,13 @@ and a same-host/different-port origin with an authenticated upstream parent,
|
||||||
and verifies successful decoding with credentials only on Jellyfin. `ConductarrAudienceRecoveryTest` covers
|
and verifies successful decoding with credentials only on Jellyfin. `ConductarrAudienceRecoveryTest` covers
|
||||||
operator success plus failed/empty audiences, name-rule retention and foreground healing.
|
operator success plus failed/empty audiences, name-rule retention and foreground healing.
|
||||||
|
|
||||||
|
`ShellStartupTimingTest` holds saved-login and real-wire activity reads pending
|
||||||
|
while exercising the picker, early tabs and a person switch. `TitleReadFacadeTest`
|
||||||
|
covers omitted held-film provider ids with authenticated cast/related enrichment.
|
||||||
|
`DiscoverTitleTimingTest` delays the cast reply, exercises real remote Down/Back
|
||||||
|
and writes native cast/related captures to `walk-fixes/`.
|
||||||
|
`NavBandOcclusionTest` checks pixels across the entire scrolled navigation band.
|
||||||
|
|
||||||
Not done here: deploying the dedicated hostname or widening server authority;
|
Not done here: deploying the dedicated hostname or widening server authority;
|
||||||
hardware installation/playback verification; releases, signing or version changes.
|
hardware installation/playback verification; releases, signing or version changes.
|
||||||
Unheld trailers remain absent because the server contract has no trailer field.
|
Unheld trailers remain absent because the server contract has no trailer field.
|
||||||
|
|
|
||||||
|
|
@ -169,7 +169,7 @@ class DiscoverState(
|
||||||
fun openTitle(item: DiscoverTitle) {
|
fun openTitle(item: DiscoverTitle) {
|
||||||
title = item; titleButton = 0
|
title = item; titleButton = 0
|
||||||
if (isSignedIn && item.ratings.isNotEmpty()) observedRatings.add(item.key)
|
if (isSignedIn && item.ratings.isNotEmpty()) observedRatings.add(item.key)
|
||||||
onPush("discover-title/${item.key}")
|
onPush("title/${android.net.Uri.encode(item.key)}")
|
||||||
}
|
}
|
||||||
fun openCollection(lane: DiscoverLane) {
|
fun openCollection(lane: DiscoverLane) {
|
||||||
var titles = lane.fullTitles.associateBy { it.key }
|
var titles = lane.fullTitles.associateBy { it.key }
|
||||||
|
|
@ -237,7 +237,7 @@ class DiscoverState(
|
||||||
/** Card hold/release handling belongs to the shell, including the shared catalogue grid. */
|
/** Card hold/release handling belongs to the shell, including the shared catalogue grid. */
|
||||||
override fun key(key: ShellKey): Boolean {
|
override fun key(key: ShellKey): Boolean {
|
||||||
if (overlay != null) { overlayKey(key); return true }
|
if (overlay != null) { overlayKey(key); return true }
|
||||||
if (shell.route != "discover" && !shell.route.startsWith("discover-")) return false
|
if (shell.route != "discover" && !shell.route.startsWith("discover-") && !shell.route.startsWith("title/")) return false
|
||||||
if (snapshot.status == DiscoverStatus.Loading) { shell.focusNav(shell.currentTab); return true }
|
if (snapshot.status == DiscoverStatus.Loading) { shell.focusNav(shell.currentTab); return true }
|
||||||
if (title == null) collection?.let {
|
if (title == null) collection?.let {
|
||||||
if (key == ShellKey.Back) { collection = null; onPop(); return true }
|
if (key == ShellKey.Back) { collection = null; onPop(); return true }
|
||||||
|
|
|
||||||
|
|
@ -71,13 +71,12 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier)
|
||||||
val height by animateFloatAsState((box.height + pad * 2).value, spring(damping, stiffness), label = "blob-height")
|
val height by animateFloatAsState((box.height + pad * 2).value, spring(damping, stiffness), label = "blob-height")
|
||||||
val color by animateColorAsState(if (state.navFocused) C.fg else C.navBlobRest, spring(damping, stiffness), label = "blob-color")
|
val color by animateColorAsState(if (state.navFocused) C.fg else C.navBlobRest, spring(damping, stiffness), label = "blob-color")
|
||||||
val density = LocalDensity.current.density
|
val density = LocalDensity.current.density
|
||||||
val band = remember(scrolled) {
|
val band = remember {
|
||||||
if (scrolled) Brush.verticalGradient(0f to C.bg, S.navSolidStop to C.bg, 1f to C.bg.copy(alpha = 0f))
|
Brush.verticalGradient(listOf(C.bg.copy(alpha = S.navBandOpacity), C.bg.copy(alpha = 0f)))
|
||||||
else Brush.verticalGradient(listOf(C.bg.copy(alpha = S.navBandOpacity), C.bg.copy(alpha = 0f)))
|
|
||||||
}
|
}
|
||||||
Box(modifier.fillMaxWidth().height(L.navBand)) {
|
Box(modifier.fillMaxWidth().height(L.navBand)) {
|
||||||
Canvas(Modifier.matchParentSize()) {
|
Canvas(Modifier.matchParentSize()) {
|
||||||
drawRect(band)
|
if (scrolled) drawRect(C.bg) else drawRect(band)
|
||||||
drawRoundRect(color, Offset(x * density, y * density), Size(width * density, height * density), CornerRadius(height * density / 2))
|
drawRoundRect(color, Offset(x * density, y * density), Size(width * density, height * density), CornerRadius(height * density / 2))
|
||||||
}
|
}
|
||||||
Wordmark(Modifier.offset(L.safeSide, S.wordmarkTop))
|
Wordmark(Modifier.offset(L.safeSide, S.wordmarkTop))
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ class DiscoverKeysTest {
|
||||||
rule.setContent {
|
rule.setContent {
|
||||||
WolfskinTheme {
|
WolfskinTheme {
|
||||||
ShellSurface(shell, overlay = { DiscoverOverlay(state) }) { modifier ->
|
ShellSurface(shell, overlay = { DiscoverOverlay(state) }) { modifier ->
|
||||||
if (shell.route == "discover" || shell.route.startsWith("discover-")) DiscoverScreen(state, modifier) else ShellPlaceholder(shell.route, modifier)
|
if (shell.route == "discover" || shell.route.startsWith("discover-") || shell.route.startsWith("title/")) DiscoverScreen(state, modifier) else ShellPlaceholder(shell.route, modifier)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue