Merge current TV recovery states into Conductarr wiring
This commit is contained in:
commit
a00db96dc8
43 changed files with 1041 additions and 199 deletions
|
|
@ -63,6 +63,7 @@ class ConductarrSurfaceTest {
|
|||
rule.mainClock.advanceTimeBy(500)
|
||||
rule.waitUntil(10_000) {
|
||||
rule.mainClock.advanceTimeByFrame()
|
||||
org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
|
||||
fake.calls.any { it.url.encodedPath == "/catalogue/titles/43/movie/ratings" }
|
||||
}
|
||||
rule.waitForIdle()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.app.Application
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import java.io.IOException
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.justinvisser.wolfskin.design.*
|
||||
import org.justinvisser.wolfskin.tv.read.HomeLibraryCache
|
||||
import org.justinvisser.wolfskin.tv.read.HomeReadFacade
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/** Saved Home projections survive outages but never survive an authenticated identity change. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class)
|
||||
class HomeRecoveryTest {
|
||||
private val context = ApplicationProvider.getApplicationContext<Application>()
|
||||
private val server = JellyfinServer(UUID.fromString("00000000-0000-0000-0000-000000000001"), "Fixture", "https://example.invalid", null)
|
||||
private val personA = JellyfinUser(id = UUID.fromString("00000000-0000-0000-0000-000000000002"), name = "Justin", serverId = server.id, accessToken = null)
|
||||
private val personB = personA.copy(id = UUID.fromString("00000000-0000-0000-0000-000000000003"), name = "Mindel")
|
||||
private val film = HomeTitle("private-film", "Person A film", "movie", held = true, runtimeSeconds = 5400)
|
||||
private val library = HomeLibrary(resume = listOf(film))
|
||||
|
||||
@Test fun savedHomePaintsBeforeRefreshAndStaysAfterFailedRetry() = runBlocking {
|
||||
val cache = HomeLibraryCache(context)
|
||||
cache.write("${server.id}:${personA.id}", library, 1000L)
|
||||
val started = CompletableDeferred<Unit>()
|
||||
val response = CompletableDeferred<HomeLibrary>()
|
||||
val facade = mockk<HomeReadFacade>()
|
||||
coEvery { facade.read(personA.id) } coAnswers { started.complete(Unit); response.await() }
|
||||
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
|
||||
val refresh = async(Dispatchers.IO) { model.loadLibrary(server, personA, "justin", retry = true) }
|
||||
started.await()
|
||||
assertEquals(film.id, model.snapshot.rows.first().cards.first().id)
|
||||
response.completeExceptionally(IOException("fixture offline"))
|
||||
refresh.await()
|
||||
assertFalse(model.snapshot.jellyfinAvailable)
|
||||
assertEquals(film.id, model.snapshot.rows.first().cards.first().id)
|
||||
assertNotNull(model.snapshot.savedAt)
|
||||
assertEquals(RecoveryMessage.StillUnreachable, model.toast)
|
||||
}
|
||||
|
||||
@Test fun personBSeesNeitherPersonAsSavedHomeNorTheirLateRefreshResult() = runBlocking {
|
||||
val facade = mockk<HomeReadFacade>()
|
||||
val cache = HomeLibraryCache(context)
|
||||
cache.write("${server.id}:${personA.id}", library, 1000L)
|
||||
val startedA = CompletableDeferred<Unit>()
|
||||
val responseA = CompletableDeferred<HomeLibrary>()
|
||||
coEvery { facade.read(personA.id) } coAnswers { startedA.complete(Unit); responseA.await() }
|
||||
coEvery { facade.read(personB.id) } throws IOException("fixture offline")
|
||||
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
|
||||
val requestA = async(Dispatchers.IO) { model.loadLibrary(server, personA, "justin") }
|
||||
startedA.await()
|
||||
assertTrue(model.snapshot.rows.any { row -> row.cards.any { it.id == film.id } })
|
||||
model.loadLibrary(server, personB, "mindel")
|
||||
assertFalse(model.snapshot.rows.any { row -> row.cards.any { it.id == film.id } })
|
||||
assertNull(model.snapshot.savedAt)
|
||||
assertTrue(model.snapshot.rows.any { it.kind == HomeRowKind.State && it.focusable })
|
||||
responseA.complete(library)
|
||||
requestA.await()
|
||||
assertFalse(model.snapshot.rows.any { row -> row.cards.any { it.id == film.id } })
|
||||
assertFalse(model.snapshot.jellyfinAvailable)
|
||||
}
|
||||
|
||||
@Test fun readableJellyfinDoesNotBecomeAnOutageWhenLocalCacheCannotBeWritten() = runBlocking {
|
||||
val facade = mockk<HomeReadFacade>()
|
||||
coEvery { facade.read(personA.id) } returns library
|
||||
val cache = mockk<HomeLibraryCache>()
|
||||
every { cache.read(any()) } returns null
|
||||
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()))
|
||||
model.loadLibrary(server, personA, "justin")
|
||||
assertTrue(model.snapshot.jellyfinAvailable)
|
||||
assertEquals(film.id, model.snapshot.rows.first().cards.first().id)
|
||||
assertNull(model.toast)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Looper
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
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.*
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
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 com.github.takahirom.roborazzi.captureRoboImage
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.UUID
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.Shadows.shadowOf
|
||||
import org.justinvisser.wolfskin.design.*
|
||||
import org.justinvisser.wolfskin.tv.read.HomeReadFacade
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/** Actual Home actions either open upstream playback or leave focus and the saved Home intact. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class PlayerHandoffTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
private val server = JellyfinServer(UUID.fromString("00000000-0000-0000-0000-000000000001"), "Fixture", "https://example.invalid", null)
|
||||
private val user = JellyfinUser(id = UUID.fromString("00000000-0000-0000-0000-000000000002"), name = "Mindel", serverId = server.id, accessToken = null)
|
||||
private val navigation = NavigationManager()
|
||||
private val facade = mockk<HomeReadFacade>()
|
||||
private lateinit var model: WolfskinHomeViewModel
|
||||
private lateinit var shell: ShellState
|
||||
|
||||
@Before fun setup() {
|
||||
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
|
||||
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()))
|
||||
}
|
||||
|
||||
@Test fun failedPlaybackReadLeavesSavedHomeFocusedAndDrawsTheExactOfflineToast() {
|
||||
val snapshot = HomeFixture(rule.activity).snapshot("mindel", "offline")
|
||||
val title = snapshot.rows.first().cards.first()
|
||||
coEvery { facade.playableId(user.id, title) } throws IOException("fixture offline")
|
||||
rule.setContent { WolfskinTheme {
|
||||
ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { modifier ->
|
||||
HomeScreen(shell, snapshot, modifier) { model.act(it, shell, server, user) }
|
||||
model.toast?.let { RecoveryToast(it) }
|
||||
}
|
||||
} }
|
||||
rule.mainClock.advanceTimeBy(1000)
|
||||
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter) }
|
||||
awaitModel { model.toast != null }
|
||||
rule.onNodeWithText(RecoveryMessage.PlaybackUnavailable).assertIsDisplayed()
|
||||
assertTrue(navigation.backStack.single() is Destination.Home)
|
||||
assertFalse(shell.navFocused)
|
||||
assertEquals("hero", snapshot.rows[shell.view.row].id)
|
||||
assertEquals(0, shell.view.cards["hero"] ?: 0)
|
||||
val directory = File(System.getProperty("wolfskin.captureDirectory"), "recovery").apply { mkdirs() }
|
||||
rule.onRoot().captureRoboImage(File(directory, "st-090-integrated.png").absolutePath)
|
||||
}
|
||||
|
||||
@Test fun successfulPlaybackUsesTheRealFullscreenDestinationAndBackKeepsTheResumeCard() {
|
||||
val title = HomeFixture(rule.activity).snapshot("mindel").rows.first().cards.first()
|
||||
val id = UUID.fromString("00000000-0000-0000-0000-000000000003")
|
||||
coEvery { facade.playableId(user.id, title) } returns id
|
||||
val view = shell.view
|
||||
view.cards["hero"] = 0
|
||||
rule.runOnIdle { model.act(HomeAction.Play(title, resume = true), shell, server, user) }
|
||||
awaitModel { navigation.backStack.last() is Destination.Playback }
|
||||
val destination = navigation.backStack.last() as Destination.Playback
|
||||
assertEquals(id, destination.itemId)
|
||||
assertEquals((title.resumeSeconds * 1000).toLong(), destination.positionMs)
|
||||
assertTrue(destination.fullScreen)
|
||||
navigation.goBack()
|
||||
assertTrue(navigation.backStack.single() is Destination.Home)
|
||||
assertSame(view, shell.view)
|
||||
assertEquals(0, shell.view.cards["hero"])
|
||||
assertNull(model.toast)
|
||||
}
|
||||
|
||||
@Test fun anotherPersonsSessionCannotResolveOrStartPlayback() {
|
||||
val title = HomeFixture(rule.activity).snapshot("mindel").rows.first().cards.first()
|
||||
val otherUser = user.copy(name = "Justin")
|
||||
rule.runOnIdle { model.act(HomeAction.Play(title, resume = true), shell, server, otherUser) }
|
||||
awaitModel { model.toast != null }
|
||||
assertEquals(RecoveryMessage.PlaybackUnavailable, model.toast)
|
||||
assertTrue(navigation.backStack.single() is Destination.Home)
|
||||
coVerify(exactly = 0) { facade.playableId(any(), any()) }
|
||||
}
|
||||
private fun awaitModel(condition: () -> Boolean) {
|
||||
rule.waitUntil(5000) {
|
||||
shadowOf(Looper.getMainLooper()).idle()
|
||||
condition()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import kotlinx.coroutines.flow.lastOrNull
|
|||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.justinvisser.wolfskin.conductarr.*
|
||||
import org.justinvisser.wolfskin.conductarr.model.LibraryAudience
|
||||
import org.justinvisser.wolfskin.conductarr.model.Options
|
||||
import org.justinvisser.wolfskin.conductarr.model.Page
|
||||
import org.justinvisser.wolfskin.design.*
|
||||
|
|
@ -22,7 +23,7 @@ internal data class CatalogueLaneQuery(
|
|||
)
|
||||
|
||||
/** Per-person read-ahead keeps unvisited shelves as nonfocusable skeletons. */
|
||||
internal class ConductarrCatalogue(private val client: ConductarrClient, val queries: List<CatalogueLaneQuery>) {
|
||||
internal class ConductarrCatalogue(private val client: ConductarrClient, val queries: List<CatalogueLaneQuery>, private val audiences: () -> List<LibraryAudience>) {
|
||||
private data class SourcePage(val number: Int, val last: Boolean, val titles: List<DiscoverTitle>, val failed: Boolean, val total: Int, val duplicateOnly: Boolean)
|
||||
private val sources = mutableMapOf<String, SourcePage>()
|
||||
private val visited = mutableSetOf<String>()
|
||||
|
|
@ -44,7 +45,7 @@ internal class ConductarrCatalogue(private val client: ConductarrClient, val que
|
|||
}
|
||||
|
||||
suspend fun more(id: String): List<DiscoverLane> = mutex.withLock {
|
||||
queries.firstOrNull { it.id == id }?.let { load(it, ReadTrigger.FORCE, true); visited.add(it.id) }
|
||||
queries.firstOrNull { it.id == id }?.let { load(it, ReadTrigger.IF_STALE, true); visited.add(it.id) }
|
||||
lanes()
|
||||
}
|
||||
|
||||
|
|
@ -55,10 +56,10 @@ internal class ConductarrCatalogue(private val client: ConductarrClient, val que
|
|||
if (next && previous?.last == true && !previous.failed) return@async key to previous
|
||||
val page = if (next && previous != null && !previous.failed) previous.number + 1 else previous?.number ?: 1
|
||||
val call = TvRoutes.discover(media, page = page, genreId = query.genres[media], serviceId = query.service, sort = query.sort)
|
||||
val result = client.read(call, trigger).lastOrNull()
|
||||
val result = client.read(call, if (previous?.failed == true && trigger != ReadTrigger.CACHE_ONLY) ReadTrigger.FORCE else trigger).lastOrNull()
|
||||
val saved = when (result) { is ReadResult.Value -> result.saved.value; is ReadResult.Failure -> result.saved?.value; else -> null }
|
||||
if (saved == null && trigger == ReadTrigger.CACHE_ONLY) return@async key to null
|
||||
val items = saved?.items.orEmpty().map { it.discoverTitle() }
|
||||
val items = saved?.items.orEmpty().map { it.discoverTitle(audiences()) }
|
||||
val titles = if (page > 1) (previous?.titles.orEmpty() + items).distinctBy { it.key } else items
|
||||
key to SourcePage(if (saved != null) page else previous?.number ?: 1,
|
||||
saved != null && saved.page >= saved.totalPages, if (saved != null) titles else previous?.titles.orEmpty(),
|
||||
|
|
@ -78,7 +79,7 @@ internal class ConductarrCatalogue(private val client: ConductarrClient, val que
|
|||
pages.any { it.duplicateOnly } -> DiscoverTail.ShowMore
|
||||
pages.any { !it.last } -> DiscoverTail.SeeAll
|
||||
else -> DiscoverTail.None
|
||||
}, pending = pending)
|
||||
}, pending = pending, hasMore = pages.any { !it.last })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,13 +14,15 @@ internal fun Candidate.discoverTitle(genres: Map<Long, String>): DiscoverTitle =
|
|||
catalogueImage(posterPath), catalogueImage(backdropPath), score = score,
|
||||
)
|
||||
|
||||
internal fun Title.discoverTitle(): DiscoverTitle = DiscoverTitle(
|
||||
internal fun Title.discoverTitle(audiences: List<LibraryAudience>): DiscoverTitle = DiscoverTitle(
|
||||
tmdbId, mediaType.name.lowercase(), title, releaseDate?.take(4)?.toIntOrNull(), overview = overview,
|
||||
poster = posterUrl, backdrop = backdropUrl,
|
||||
heldBy = audiences.filter { audience -> audience.libraries.orEmpty().any { it in libraries.orEmpty() } }.mapTo(mutableSetOf()) { it.owner.lowercase() },
|
||||
)
|
||||
|
||||
internal fun DiscoverSnapshot.homeRows(): List<HomeRow> {
|
||||
if (status == DiscoverStatus.NeedsSignIn) return listOf(homeDiscoverState(HomeAvailability.SignedOut))
|
||||
if (status == DiscoverStatus.Loading) return listOf(HomeRow("catalogue-loading", HomeRowKind.Shelf, skeleton = true, catalogue = true))
|
||||
if (status == DiscoverStatus.Unavailable) return listOf(homeDiscoverState(HomeAvailability.Unavailable))
|
||||
val personal = listOf("movie" to "Movies for you", "tv" to "Series for you").mapNotNull { (type, label) ->
|
||||
picks.filter { it.type == type }.takeIf { it.isNotEmpty() }?.let { items ->
|
||||
|
|
@ -28,8 +30,9 @@ internal fun DiscoverSnapshot.homeRows(): List<HomeRow> {
|
|||
}
|
||||
}
|
||||
return personal + lanes.filterNot { it.id.startsWith("soon") }.mapNotNull { lane ->
|
||||
lane.titles.takeIf { it.isNotEmpty() }?.let { items -> HomeRow(lane.id, HomeRowKind.Shelf, lane.title,
|
||||
items.take(WolfskinTokens.Behaviour.rowLimit.toInt()).map { it.homeTitle() }, catalogue = true, total = items.size, allCards = items.map { it.homeTitle() }) }
|
||||
if (lane.pending) HomeRow(lane.id, HomeRowKind.Shelf, lane.title, catalogue = true, skeleton = true)
|
||||
else lane.titles.takeIf { it.isNotEmpty() }?.let { items -> HomeRow(lane.id, HomeRowKind.Shelf, lane.title,
|
||||
items.take(WolfskinTokens.Behaviour.rowLimit.toInt()).map { it.homeTitle() }, catalogue = true, total = if (lane.hasMore) lane.total else items.size, allCards = items.map { it.homeTitle() }) }
|
||||
} + HomeRow("explore", HomeRowKind.Explore)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ class ConductarrProvider(val client: ConductarrClient) : ConductarrTitleProvider
|
|||
private var catalogue: ConductarrCatalogue? = null
|
||||
private var options = emptyList<Options>()
|
||||
private val services = mutableMapOf<String, ConductarrCatalogue>()
|
||||
@Volatile private var libraryAudiences = emptyList<LibraryAudience>()
|
||||
fun discover(profile: String, force: Boolean = false): Flow<DiscoverSnapshot> = flow {
|
||||
val saved = discoverSnapshot(profile, ReadTrigger.CACHE_ONLY)
|
||||
if (saved.picks.isNotEmpty() || saved.lanes.any { it.titles.isNotEmpty() }) emit(saved)
|
||||
|
|
@ -34,7 +35,7 @@ class ConductarrProvider(val client: ConductarrClient) : ConductarrTitleProvider
|
|||
val options = optionsJobs.mapNotNull { it.await() }
|
||||
this@ConductarrProvider.options = options
|
||||
val queries = catalogueQueries(options)
|
||||
val loader = catalogue?.takeIf { it.queries == queries } ?: ConductarrCatalogue(client, queries).also { catalogue = it }
|
||||
val loader = catalogue?.takeIf { it.queries == queries } ?: ConductarrCatalogue(client, queries) { libraryAudiences }.also { catalogue = it }
|
||||
val lanes = if (trigger == ReadTrigger.CACHE_ONLY) loader.saved() else loader.through(1, force = trigger == ReadTrigger.FORCE)
|
||||
val genres = options.flatMap { it.genres.orEmpty() }.associate { it.id to it.name }
|
||||
val picks = (feed?.movies.orEmpty() + feed?.shows.orEmpty()).map { it.discoverTitle(genres) }
|
||||
|
|
@ -66,7 +67,7 @@ class ConductarrProvider(val client: ConductarrClient) : ConductarrTitleProvider
|
|||
|
||||
suspend fun service(service: DiscoverService, type: String, through: Int = 1, more: String? = null): List<DiscoverLane> {
|
||||
val key = "${service.id}:$type"
|
||||
val loader = services.getOrPut(key) { ConductarrCatalogue(client, catalogueQueries(options, service, type)) }
|
||||
val loader = services.getOrPut(key) { ConductarrCatalogue(client, catalogueQueries(options, service, type)) { libraryAudiences } }
|
||||
return if (more != null) loader.more(more) else loader.through(through)
|
||||
}
|
||||
|
||||
|
|
@ -75,21 +76,28 @@ class ConductarrProvider(val client: ConductarrClient) : ConductarrTitleProvider
|
|||
val reads = SnapshotReads(client, trigger)
|
||||
val feed = reads.read(TvRoutes.activity())
|
||||
if (feed == null && trigger == ReadTrigger.CACHE_ONLY) continue
|
||||
val artReads = SnapshotReads(client, trigger)
|
||||
val items = coroutineScope {
|
||||
feed?.items.orEmpty().map { item -> async {
|
||||
val media = if (item.mediaType == RequestProgress.MediaType.MOVIE) MediaType.MOVIE else MediaType.TV
|
||||
val art = reads.read(TvRoutes.display(item.tmdbId, media), if (trigger == ReadTrigger.CACHE_ONLY) trigger else ReadTrigger.IF_STALE)
|
||||
val art = artReads.read(TvRoutes.display(item.tmdbId, media), if (trigger == ReadTrigger.CACHE_ONLY) trigger else ReadTrigger.IF_STALE)
|
||||
item.comingIn().copy(poster = art?.posterUrl)
|
||||
} }.map { it.await() }
|
||||
}
|
||||
emit(ComingInSnapshot(items,
|
||||
if (feed == null) ComingInStatus.Unavailable else ComingInStatus.Ready,
|
||||
partial = feed?.truncated == true || reads.failed, signedIn = !reads.needsSignIn && feed != null))
|
||||
if (feed == null || reads.failed) ComingInStatus.Unavailable else ComingInStatus.Ready,
|
||||
partial = feed?.truncated == true || reads.failed, signedIn = !reads.needsSignIn && !artReads.needsSignIn && feed != null,
|
||||
savedAt = reads.savedTime()))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun profiles(): RecommendationProfiles? = SnapshotReads(client, ReadTrigger.LAUNCH).read(TvRoutes.profiles())
|
||||
suspend fun audiences(): List<LibraryAudience> = SnapshotReads(client, ReadTrigger.IF_STALE).read(TvRoutes.audiences())?.audiences.orEmpty()
|
||||
suspend fun audiences(): List<LibraryAudience> {
|
||||
libraryAudiences = SnapshotReads(client, ReadTrigger.IF_STALE).read(TvRoutes.audiences())?.audiences.orEmpty()
|
||||
return libraryAudiences
|
||||
}
|
||||
|
||||
fun holders(title: Title): Set<String> = title.discoverTitle(libraryAudiences).heldBy
|
||||
}
|
||||
|
||||
/** A batch tracks source failure without replacing saved bodies with empty success. */
|
||||
|
|
|
|||
|
|
@ -9,15 +9,16 @@ import org.justinvisser.wolfskin.design.ScreenRatings
|
|||
|
||||
/** Failed reads are not cached as absence; saved observations survive offline. */
|
||||
class ConductarrRatings(private val client: ConductarrClient) : ScreenRatings {
|
||||
override suspend fun read(tmdb: Long, type: String, season: Int?, episode: Int?): List<LibraryRating>? {
|
||||
override suspend fun read(tmdb: Long, type: String, season: Int?, episode: Int?, cachedOnly: Boolean): List<LibraryRating>? {
|
||||
val media = if (type == "movie") MediaType.MOVIE else MediaType.TV
|
||||
val ratings = when (val result = client.read(TvRoutes.ratings(tmdb, media)).lastOrNull()) {
|
||||
val trigger = if (cachedOnly) ReadTrigger.CACHE_ONLY else ReadTrigger.IF_STALE
|
||||
val ratings = when (val result = client.read(TvRoutes.ratings(tmdb, media), trigger).lastOrNull()) {
|
||||
is ReadResult.Value -> result.saved.value?.ratings.orEmpty().screenRatings(type)
|
||||
is ReadResult.Failure -> result.saved?.value?.ratings?.screenRatings(type) ?: return null
|
||||
null -> return null
|
||||
}
|
||||
if (type != "episode" || season == null || episode == null) return ratings
|
||||
val result = client.read(TvRoutes.season(tmdb, season), ReadTrigger.IF_STALE).lastOrNull()
|
||||
val result = client.read(TvRoutes.season(tmdb, season), trigger).lastOrNull()
|
||||
val detail = when (result) { is ReadResult.Value -> result.saved.value; is ReadResult.Failure -> result.saved?.value; else -> null }
|
||||
val vote = detail?.episodes?.firstOrNull { it.number == episode.toLong() }
|
||||
val score = vote?.voteAverage?.takeIf { it.isFinite() && it > 0 && it <= 10 && (vote.voteCount ?: 0) > 0 } ?: return ratings
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ internal fun rememberDiscoverState(
|
|||
val scope = rememberCoroutineScope()
|
||||
val servicePages = remember(identity?.client, shell.person.key) { mutableStateMapOf<String, List<DiscoverLane>>() }
|
||||
val refresh: suspend () -> DiscoverSnapshot = {
|
||||
if (identity?.signedIn == true) identity.provider.discover(identity.operator!!.recommendationProfile, force = true).lastOrNull() ?: DiscoverSnapshot(status = DiscoverStatus.Unavailable)
|
||||
if (identity?.signedIn == true) identity.provider.discover(identity.operator!!.recommendationProfile, force = true).lastOrNull()?.copy(requestsAllowed = identity.operator.capabilities.requestMedia) ?: DiscoverSnapshot(status = DiscoverStatus.Unavailable)
|
||||
else { retrySignIn(); DiscoverSnapshot() }
|
||||
}
|
||||
val state = remember(shell, shell.person.key, identity?.client, identity?.signedIn, identity?.error, fixtures) {
|
||||
|
|
@ -59,7 +59,7 @@ internal fun rememberDiscoverState(
|
|||
fixtureState = loaded
|
||||
if (shell.route == "discover" && (!shell.navFocused || shell.navIndex == shell.currentTab)) loaded.focus(if (loaded.hasHero || !loaded.canRequest) 0 else 1)
|
||||
} else if (identity?.signedIn == true) {
|
||||
identity.provider.discover(identity.operator!!.recommendationProfile).collect { state.updateSnapshot(it) }
|
||||
identity.provider.discover(identity.operator!!.recommendationProfile).collect { state.updateSnapshot(it.copy(requestsAllowed = identity.operator.capabilities.requestMedia)) }
|
||||
}
|
||||
}
|
||||
LaunchedEffect(state.service?.id, state.filter, state) {
|
||||
|
|
@ -75,12 +75,16 @@ internal fun rememberDiscoverState(
|
|||
if (service == null) {
|
||||
state.updateLanes(identity.provider.readAhead(lane))
|
||||
val focused = state.focusedLane
|
||||
if (focused != null && focused.tail == DiscoverTail.SeeAll && column >= focused.titles.size - WolfskinTokens.Behaviour.nextPageCards) {
|
||||
if (focused != null && focused.hasMore && focused.tail == DiscoverTail.SeeAll && column >= focused.fullTitles.size - WolfskinTokens.Behaviour.nextPageCards) {
|
||||
state.updateLanes(identity.provider.more(focused.id))
|
||||
}
|
||||
} else {
|
||||
val index = state.lanes.indexOfFirst { it.id == lane }
|
||||
servicePages["${service.id}:${state.filter}"] = identity.provider.service(service, state.filter, through = index + 2)
|
||||
val focused = state.focusedLane
|
||||
if (focused != null && focused.hasMore && focused.tail == DiscoverTail.SeeAll && column >= focused.fullTitles.size - WolfskinTokens.Behaviour.nextPageCards) {
|
||||
servicePages["${service.id}:${state.filter}"] = identity.provider.service(service, state.filter, more = focused.id)
|
||||
}
|
||||
state.invalidateLanes()
|
||||
}
|
||||
}
|
||||
|
|
@ -93,7 +97,7 @@ internal fun rememberDiscoverState(
|
|||
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))
|
||||
seasons = detail.seasons?.map { it.number.toInt() }, seasonCount = detail.seasons?.size, heldBy = identity.provider.holders(detail.title)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,12 @@
|
|||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
|
|
@ -40,11 +37,16 @@ class WolfskinHomeViewModel @Inject constructor(
|
|||
private val conductarr: ConductarrSession,
|
||||
) : ViewModel() {
|
||||
val conductarrState get() = conductarr.identity
|
||||
val audiences get() = home.audiences
|
||||
var snapshot by mutableStateOf(HomeSnapshot(buildHomeRows(HomeLibrary(), emptyList(), loading = true), HomeAvailability.Loading))
|
||||
private set
|
||||
var toast by mutableStateOf<String?>(null)
|
||||
private set
|
||||
private var catalogueJob: kotlinx.coroutines.Job? = null
|
||||
private var sourceScope: String? = null
|
||||
private var loadRevision = 0L
|
||||
private var catalogueSnapshot = DiscoverSnapshot()
|
||||
private var publishCatalogue: ((DiscoverSnapshot) -> Unit)? = null
|
||||
|
||||
suspend fun load(server: JellyfinServer, user: JellyfinUser, person: String, retry: Boolean = false) {
|
||||
catalogueJob?.cancel()
|
||||
|
|
@ -53,65 +55,110 @@ class WolfskinHomeViewModel @Inject constructor(
|
|||
conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true)
|
||||
conductarr.authenticate()
|
||||
}
|
||||
val continuation = listOf(homeDiscoverState(HomeAvailability.SignedOut))
|
||||
if (person != user.name.orEmpty().lowercase() && person != conductarr.identity.value?.operator?.requester) {
|
||||
snapshot = HomeSnapshot(buildHomeRows(HomeLibrary(), continuation, libraryAvailable = false), jellyfinAvailable = false)
|
||||
loadLibrary(server, user, person, retry)
|
||||
}
|
||||
|
||||
/** The authenticated read path is shared by release and offline fixture-driven tests. */
|
||||
internal suspend fun loadLibrary(server: JellyfinServer, user: JellyfinUser, person: String, retry: Boolean = false) {
|
||||
val revision = ++loadRevision
|
||||
if (person != user.name.orEmpty().lowercase() && !(conductarr.identity.value?.person == "${server.id}:${user.id}" && person == conductarr.identity.value?.operator?.requester)) {
|
||||
snapshot = HomeSnapshot(buildHomeRows(HomeLibrary(), listOf(homeDiscoverState(HomeAvailability.SignedOut)), libraryAvailable = false), jellyfinAvailable = false)
|
||||
return
|
||||
}
|
||||
val scope = "${server.id}:${user.id}"
|
||||
val saved = withContext(Dispatchers.IO) { cache.read(scope) }
|
||||
if (sourceScope != scope) {
|
||||
sourceScope = scope
|
||||
toast = null
|
||||
snapshot = HomeSnapshot(buildHomeRows(HomeLibrary(), emptyList(), loading = true), HomeAvailability.Loading)
|
||||
}
|
||||
val saved = withContext(Dispatchers.IO) { try { cache.read(scope) } catch (_: java.io.IOException) { null } }
|
||||
if (revision != loadRevision) return
|
||||
var libraryCopy = saved?.first ?: HomeLibrary()
|
||||
if (saved != null) snapshot = HomeSnapshot(buildHomeRows(saved.first, continuation))
|
||||
var libraryAvailable = true
|
||||
var librarySavedAt: String? = null
|
||||
val identity = conductarr.identity.value
|
||||
var catalogueCopy = DiscoverSnapshot(status = if (identity?.signedIn == true) DiscoverStatus.Loading else DiscoverStatus.NeedsSignIn)
|
||||
fun publish() {
|
||||
if (revision != loadRevision || sourceScope != scope) return
|
||||
val status = when (catalogueCopy.status) {
|
||||
DiscoverStatus.NeedsSignIn -> HomeAvailability.SignedOut
|
||||
DiscoverStatus.Loading -> HomeAvailability.Loading
|
||||
DiscoverStatus.Unavailable -> HomeAvailability.Unavailable
|
||||
else -> if (catalogueCopy.savedAt != null) HomeAvailability.Saved else HomeAvailability.Ready
|
||||
}
|
||||
snapshot = HomeSnapshot(buildHomeRows(libraryCopy, catalogueCopy.homeRows(), libraryAvailable = libraryAvailable),
|
||||
status, libraryAvailable, catalogueCopy.savedAt ?: librarySavedAt)
|
||||
}
|
||||
catalogueSnapshot = catalogueCopy
|
||||
publishCatalogue = { value -> if (revision == loadRevision && sourceScope == scope) { catalogueCopy = value; catalogueSnapshot = value; publish() } }
|
||||
if (saved != null) publish()
|
||||
if (identity?.signedIn == true) catalogueJob = viewModelScope.launch {
|
||||
identity.provider.discover(identity.operator!!.recommendationProfile, retry).collect { catalogue ->
|
||||
if (revision == loadRevision && conductarr.identity.value?.client === identity.client) publishCatalogue?.invoke(catalogue)
|
||||
}
|
||||
}
|
||||
try {
|
||||
val library = withContext(Dispatchers.IO) { home.read(user.id) }
|
||||
val savedAt = System.currentTimeMillis()
|
||||
withContext(Dispatchers.IO) { try { cache.write(scope, library, savedAt) } catch (_: java.io.IOException) { /* A disk failure must not turn a reachable source into an outage. */ } }
|
||||
libraryCopy = library
|
||||
withContext(Dispatchers.IO) { cache.write(scope, library, savedAt) }
|
||||
snapshot = HomeSnapshot(buildHomeRows(library, continuation))
|
||||
publish()
|
||||
} catch (exception: Exception) {
|
||||
if (exception is CancellationException) throw exception
|
||||
snapshot = if (saved != null) HomeSnapshot(buildHomeRows(saved.first, continuation), jellyfinAvailable = false, savedAt = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(saved.second)))
|
||||
else HomeSnapshot(buildHomeRows(HomeLibrary(), continuation, libraryAvailable = false), jellyfinAvailable = false)
|
||||
if (retry) showToast("Still can’t reach it. The last saved view stays on screen.")
|
||||
}
|
||||
val identity = conductarr.identity.value
|
||||
if (identity?.signedIn == true) catalogueJob = viewModelScope.launch {
|
||||
identity.provider.discover(identity.operator!!.recommendationProfile, retry).collect { catalogue ->
|
||||
if (conductarr.identity.value?.client !== identity.client) return@collect
|
||||
val status = when (catalogue.status) {
|
||||
DiscoverStatus.NeedsSignIn -> HomeAvailability.SignedOut
|
||||
DiscoverStatus.Unavailable -> HomeAvailability.Unavailable
|
||||
else -> if (catalogue.savedAt != null) HomeAvailability.Saved else HomeAvailability.Ready
|
||||
}
|
||||
snapshot = HomeSnapshot(buildHomeRows(libraryCopy, catalogue.homeRows(), libraryAvailable = snapshot.jellyfinAvailable),
|
||||
status, snapshot.jellyfinAvailable, catalogue.savedAt ?: snapshot.savedAt)
|
||||
}
|
||||
if (revision != loadRevision) return
|
||||
libraryAvailable = false
|
||||
librarySavedAt = saved?.second?.let { SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(it)) }
|
||||
publish()
|
||||
if (retry) showToast(RecoveryMessage.StillUnreachable)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun readAhead(lane: String?) {
|
||||
val identity = conductarr.identity.value ?: return
|
||||
if (!identity.signedIn || ConductarrPreference.fixtures) return
|
||||
val revision = loadRevision
|
||||
val lanes = identity.provider.readAhead(lane)
|
||||
if (revision == loadRevision && conductarr.identity.value?.client === identity.client) publishCatalogue?.invoke(catalogueSnapshot.copy(lanes = lanes))
|
||||
}
|
||||
|
||||
fun act(action: HomeAction, shell: ShellState, server: JellyfinServer, user: JellyfinUser) {
|
||||
when (action) {
|
||||
HomeAction.Discover -> openRoot(shell, "discover")
|
||||
HomeAction.Retry -> viewModelScope.launch { load(server, user, shell.person.key, retry = true) }
|
||||
is HomeAction.SeeAll -> if (action.row.target in shell.tabs) openRoot(shell, action.row.target!!) else seeAll.open(shell.person.key, action.row.id, action.row.title, action.row.allCards.map { it.libraryTitle() }, navigation)
|
||||
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("title:${action.title.type}:${action.title.tmdbId ?: action.title.id}"))
|
||||
is HomeAction.Play -> viewModelScope.launch {
|
||||
if (!snapshot.jellyfinAvailable || shell.person.key != user.name.orEmpty().lowercase()) {
|
||||
showToast("Can’t reach Jellyfin to play. Check the network and try again.")
|
||||
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)
|
||||
if (!snapshot.jellyfinAvailable || !matchesUser) {
|
||||
showToast(RecoveryMessage.PlaybackUnavailable)
|
||||
return@launch
|
||||
}
|
||||
try {
|
||||
val id = withContext(Dispatchers.IO) { home.playableId(user.id, action.title) }
|
||||
if (id == null) showToast("Can’t reach Jellyfin to play. Check the network and try again.")
|
||||
if (id == null) showToast(RecoveryMessage.PlaybackUnavailable)
|
||||
else navigation.navigateTo(Destination.Playback(id, if (action.resume) (action.title.resumeSeconds * 1000).toLong() else 0L))
|
||||
} catch (exception: Exception) {
|
||||
if (exception is CancellationException) throw exception
|
||||
showToast("Can’t reach Jellyfin to play. Check the network and try again.")
|
||||
showToast(RecoveryMessage.PlaybackUnavailable)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun openCatalogueGrid(row: HomeRow, shell: ShellState) {
|
||||
val identity = conductarr.identity.value
|
||||
val lane = catalogueSnapshot.lanes.firstOrNull { it.id == row.id }
|
||||
val loadMore: (suspend (Int) -> LibraryPage)? = if (identity?.signedIn == true && lane?.hasMore == true) { _ ->
|
||||
if (conductarr.identity.value?.client !== identity.client) throw java.io.IOException("Selected person changed")
|
||||
val next = identity.provider.more(lane.id).first { it.id == lane.id }
|
||||
if (next.tail == DiscoverTail.Retry) throw java.io.IOException("Catalogue unavailable")
|
||||
LibraryPage(next.fullTitles.map { it.homeTitle().libraryTitle() }, next.hasMore)
|
||||
} else null
|
||||
seeAll.open(shell.person.key, row.id, row.title, row.allCards.map { it.libraryTitle() }, navigation, loadMore)
|
||||
}
|
||||
|
||||
private fun openRoot(shell: ShellState, route: String) {
|
||||
shell.enterDestination(route)
|
||||
shell.focusNav(shell.tabs.indexOf(route))
|
||||
|
|
@ -147,12 +194,15 @@ internal fun HomeTitle.libraryTitle(): LibraryTitle = LibraryTitle(
|
|||
@Composable
|
||||
fun HomeDestination(shell: ShellState, server: JellyfinServer, user: JellyfinUser, modifier: Modifier = Modifier, model: WolfskinHomeViewModel = hiltViewModel()) {
|
||||
val identity by model.conductarrState.collectAsState()
|
||||
LaunchedEffect(shell.person.key, server.id, user.id, identity?.client, identity?.signedIn) { model.load(server, user, shell.person.key) }
|
||||
LaunchedEffect(shell.person, server.id, user.id, identity?.client, identity?.signedIn, model.audiences) { model.load(server, user, shell.person.key) }
|
||||
val view = shell.rememberedView("home")
|
||||
LaunchedEffect(view.row, model.snapshot.rows) {
|
||||
val lane = model.snapshot.rows.drop(view.row).take(3).lastOrNull { it.catalogue }?.id
|
||||
model.readAhead(lane)
|
||||
}
|
||||
Box(modifier) {
|
||||
val shown = if (!ConductarrPreference.fixtures && identity?.signedIn != true) model.snapshot.copy(conductarr = HomeAvailability.SignedOut) else model.snapshot
|
||||
HomeScreen(shell, shown, Modifier.fillMaxSize()) { model.act(it, shell, server, user) }
|
||||
model.toast?.let { message ->
|
||||
Text(message, color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.body, modifier = Modifier.align(Alignment.TopEnd).padding(top = WolfskinTokens.Home.toastTop, end = WolfskinTokens.Layout.safeSide).widthIn(max = WolfskinTokens.Home.toastWidth).background(WolfskinTokens.Color.surface2).padding(WolfskinTokens.Home.toastPadding))
|
||||
}
|
||||
model.toast?.let { RecoveryToast(it) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import org.justinvisser.wolfskin.design.WolfskinTokens
|
|||
@Composable
|
||||
internal fun RequestsRefresh(state: ShellState, refresh: () -> Unit) {
|
||||
val owner = LocalLifecycleOwner.current
|
||||
LaunchedEffect(state, owner) {
|
||||
LaunchedEffect(state, state.person.key, owner) {
|
||||
owner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
refreshRequestsWhileStarted(state, refresh)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -137,18 +137,26 @@ class WolfskinShellViewModel @Inject constructor(
|
|||
|
||||
fun clearBackdrop() { viewModelScope.launch { backdrop.clearBackdrop() } }
|
||||
|
||||
/** Requests are person-scoped; a failed refresh retains the provider's saved observations. */
|
||||
fun refreshRequests() {
|
||||
/** Capture the person's state before I/O so a late refresh cannot paint another person's sheet. */
|
||||
fun refreshRequests() = refreshRequests(retry = false)
|
||||
|
||||
private fun refreshRequests(retry: Boolean) {
|
||||
val shell = session.state ?: return
|
||||
val target = shell.requests
|
||||
val identity = conductarr.identity.value
|
||||
viewModelScope.launch {
|
||||
if (ConductarrPreference.fixtures) {
|
||||
session.state?.requests?.update(withContext(Dispatchers.IO) { requestsSnapshot })
|
||||
} else {
|
||||
val identity = conductarr.identity.value
|
||||
if (identity?.signedIn != true) session.state?.requests?.update(org.justinvisser.wolfskin.design.ComingInSnapshot())
|
||||
else identity.provider.requests().collect { snapshot ->
|
||||
if (conductarr.identity.value?.client === identity.client) session.state?.requests?.update(snapshot)
|
||||
target.update(withContext(Dispatchers.IO) { requestsSnapshot })
|
||||
} else if (identity?.signedIn != true) {
|
||||
target.update(org.justinvisser.wolfskin.design.ComingInSnapshot())
|
||||
} else identity.provider.requests().collect { snapshot ->
|
||||
if (conductarr.identity.value?.client === identity.client) {
|
||||
target.update(if (conductarr.identity.value?.signedIn == true) snapshot else org.justinvisser.wolfskin.design.ComingInSnapshot())
|
||||
}
|
||||
}
|
||||
if (retry && target.snapshot.status == org.justinvisser.wolfskin.design.ComingInStatus.Unavailable && shell.requests === target) {
|
||||
shell.toast = org.justinvisser.wolfskin.design.RecoveryMessage.StillUnreachable
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -161,7 +169,7 @@ class WolfskinShellViewModel @Inject constructor(
|
|||
ShellAction.RetryPeople -> viewModelScope.launch { retryConductarr(server, user) }
|
||||
ShellAction.RetryRequests -> viewModelScope.launch {
|
||||
if (conductarr.identity.value?.signedIn != true) retryConductarr(server, user)
|
||||
refreshRequests()
|
||||
refreshRequests(retry = true)
|
||||
}
|
||||
is ShellAction.RequestTitle -> navigation.navigateTo(Destination.Wolfskin("request-title/${action.request.mediaType}/${action.request.tmdbId}"))
|
||||
}
|
||||
|
|
@ -267,7 +275,7 @@ fun WolfskinApp(
|
|||
ShellSurface(state, handlesContentKeys = upstream == null,
|
||||
overlay = { if (route == "discover" || route.startsWith("discover-")) DiscoverOverlay(discover) }) { contentModifier ->
|
||||
when {
|
||||
(upstream != null || destination is Destination.Home) && 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 -> ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", contentModifier)
|
||||
upstream != null -> DestinationContent(upstream, preferences, viewModel::clearBackdrop, contentModifier)
|
||||
destination is Destination.Home -> HomeDestination(state, server, user, contentModifier)
|
||||
route in LIBRARY_DESTINATIONS || route.startsWith("seeall/") || route.startsWith("library-title/") ->
|
||||
|
|
|
|||
|
|
@ -51,7 +51,8 @@ fun WolfskinLibraryContent(
|
|||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val view = shell.rememberedView(route)
|
||||
val fixture by produceState<LibraryFixture?>(null, context) {
|
||||
val fixture by produceState<LibraryFixture?>(null, context, ConductarrPreference.fixtures) {
|
||||
value = null
|
||||
if (ConductarrPreference.fixtures) value = withContext(Dispatchers.IO) {
|
||||
context.assets.open("wolfskin-library.json").bufferedReader().use { LibraryFixture(it.readText(), "file:///android_asset/library/") }
|
||||
}
|
||||
|
|
@ -62,7 +63,7 @@ fun WolfskinLibraryContent(
|
|||
return
|
||||
}
|
||||
val grid = remember(shell.person.key, route, view) {
|
||||
view.library ?: if (route.startsWith("seeall/")) seeAll.get(route.substringAfter("seeall/")) else LibraryGridState(
|
||||
view.library ?: if (route.startsWith("seeall/")) seeAll.get(route.substringAfter("seeall/").takeIf { it.startsWith("${shell.person.key}/") } ?: "") else LibraryGridState(
|
||||
title = route.replaceFirstChar { it.uppercase() }, items = emptyList(),
|
||||
onTitle = { navigation.navigateTo(Destination.Wolfskin("library-title/${it.id}")) },
|
||||
onDiscover = { shell.enterDestination("discover"); navigation.replace(Destination.Wolfskin("discover")) },
|
||||
|
|
@ -73,7 +74,7 @@ fun WolfskinLibraryContent(
|
|||
ShellPlaceholder("See all list unavailable", modifier)
|
||||
return
|
||||
}
|
||||
LaunchedEffect(grid, grid.refreshRevision, fixture) {
|
||||
LaunchedEffect(grid, grid.refreshRevision, fixture, if (route in LIBRARY_DESTINATIONS) facade.audiences else null) {
|
||||
if (route in LIBRARY_DESTINATIONS && (!ConductarrPreference.fixtures || fixture != null)) {
|
||||
val wasLoading = grid.status == LibraryStatus.Loading
|
||||
if (fixture != null) {
|
||||
|
|
@ -84,6 +85,8 @@ fun WolfskinLibraryContent(
|
|||
grid.source = snapshot.items
|
||||
grid.status = if (snapshot.unreachable && snapshot.savedAtMillis == null) LibraryStatus.Unavailable else LibraryStatus.Ready
|
||||
grid.savedAt = if (snapshot.unreachable) snapshot.savedAtMillis?.let { DateTimeFormatter.ofPattern("HH:mm").format(Instant.ofEpochMilli(it).atZone(ZoneId.systemDefault())) } else null
|
||||
grid.tail = if (snapshot.unreachable && snapshot.items.isNotEmpty()) GridTail.Retry else GridTail.None
|
||||
if (snapshot.unreachable && grid.refreshRevision > 0) shell.toast = RecoveryMessage.StillUnreachable
|
||||
}
|
||||
}
|
||||
if (wasLoading && shell.navFocused && shell.navIndex == shell.currentTab) shell.navFocused = false
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import org.justinvisser.wolfskin.design.HomeTitle
|
|||
|
||||
/** All Home SDK calls and image URLs stay behind this facade, with their own required field list. */
|
||||
class HomeReadFacade @Inject constructor(private val api: ApiClient, private val images: ImageUrlService, private val libraries: LibraryReadFacade) {
|
||||
val audiences get() = libraries.audiences
|
||||
suspend fun read(userId: UUID): HomeLibrary {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
package org.justinvisser.wolfskin.tv.read
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
||||
|
|
@ -18,7 +22,7 @@ import javax.inject.Inject
|
|||
/** SDK reads are confined here so shell and screen state never depend on SDK responses. */
|
||||
@javax.inject.Singleton
|
||||
class LibraryReadFacade @Inject constructor(private val api: ApiClient) {
|
||||
var audiences: List<org.justinvisser.wolfskin.conductarr.model.LibraryAudience>? = null
|
||||
var audiences by mutableStateOf<List<org.justinvisser.wolfskin.conductarr.model.LibraryAudience>?>(null)
|
||||
|
||||
fun destination(name: String, collection: CollectionType?): String? {
|
||||
audiences?.let { known ->
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue