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 {
|
||||
assertNotNull(current.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 seasonTwoScore = 8.1
|
||||
@Volatile var localArt = false
|
||||
@Volatile var beforeResponse: ((String) -> Unit)? = null
|
||||
private val imageBytes = java.util.Base64.getDecoder().decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jYioAAAAASUVORK5CYII=")
|
||||
|
||||
|
|
@ -37,6 +38,7 @@ internal class FakeConductarr : Closeable {
|
|||
if (localArt && request.url.encodedPath == "/image") return MockResponse.Builder()
|
||||
.body(okio.Buffer().write(imageBytes)).build()
|
||||
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") return response(wire.getValue("hide").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.currentUser } 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 }
|
||||
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)))
|
||||
}
|
||||
|
||||
@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 {
|
||||
coEvery { library.getItem(firstId) } returns successResponse(file(firstId))
|
||||
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
|
||||
LaunchedEffect(shell.route, result) {
|
||||
result.enterRoute(shell.route)
|
||||
|
|
|
|||
|
|
@ -95,14 +95,14 @@ class WolfskinShellViewModel @Inject constructor(
|
|||
if (ConductarrPreference.fixtures) conductarr.close()
|
||||
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()
|
||||
session.onAction = { action -> handleAction(action, server, user) }
|
||||
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()
|
||||
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 {
|
||||
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. */
|
||||
suspend fun read(id: UUID, season: Int?, preferences: UserPreferences): TitlePageData = withContext(Dispatchers.IO) {
|
||||
val file = cacheFile(id)
|
||||
val opened = api.userLibraryApi.getItem(id).content
|
||||
val series = if (opened.type == BaseItemKind.EPISODE && opened.seriesId != null) api.userLibraryApi.getItem(opened.seriesId!!).content else opened
|
||||
val opened = readItem(id)
|
||||
val series = if (opened.type == BaseItemKind.EPISODE && opened.seriesId != null) readItem(opened.seriesId!!) else opened
|
||||
val seasons = if (series.type == BaseItemKind.SERIES) api.itemsApi.getItems(GetItemsRequest(parentId = series.id, includeItemTypes = listOf(BaseItemKind.SEASON), recursive = false, sortBy = listOf(ItemSortBy.INDEX_NUMBER))).content.items.orEmpty() else emptyList()
|
||||
val next = if (opened.type == BaseItemKind.EPISODE || opened.type == BaseItemKind.MOVIE) opened else {
|
||||
api.tvShowsApi.getNextUp(GetNextUpRequest(seriesId = series.id, userId = repository.currentUser?.id, limit = 1, fields = fields, enableResumable = true)).content.items.firstOrNull()?.let { api.userLibraryApi.getItem(it.id).content }
|
||||
|
|
@ -107,6 +107,20 @@ class TitleReadFacade @Inject constructor(
|
|||
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. */
|
||||
suspend fun preparePlayback(id: UUID) { api.userLibraryApi.getItem(id) }
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue