Integrate Requests with the shared image cache
217
app/src/debug/assets/wolfskin-requests.json
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
{
|
||||
"source": "Locked Opus fixture/fixture.json; index.html EXAMPLES() pool order",
|
||||
"items": [
|
||||
{
|
||||
"id": "x1",
|
||||
"mediaType": "tv",
|
||||
"tmdbId": 11821,
|
||||
"title": "The Abbott and Costello Show",
|
||||
"requesterKey": "justin",
|
||||
"requesterName": "Justin",
|
||||
"actorKey": "justin",
|
||||
"actorName": "Justin",
|
||||
"stage": "Downloading",
|
||||
"poster": "tv-the-abbott-and-costello-show-1952-poster.jpg",
|
||||
"ratings": [
|
||||
{
|
||||
"mark": "tmdb",
|
||||
"score": 7.2,
|
||||
"percentage": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "x2",
|
||||
"mediaType": "movie",
|
||||
"tmdbId": 13483,
|
||||
"title": "Awake",
|
||||
"requesterKey": "mindel",
|
||||
"requesterName": "Mindel",
|
||||
"actorKey": "justin",
|
||||
"actorName": "Justin",
|
||||
"stage": "AlmostReady",
|
||||
"poster": "movie-awake-2007-poster.jpg",
|
||||
"ratings": [
|
||||
{
|
||||
"mark": "rotten",
|
||||
"score": 23,
|
||||
"percentage": true
|
||||
},
|
||||
{
|
||||
"mark": "spilled",
|
||||
"score": 55,
|
||||
"percentage": true
|
||||
},
|
||||
{
|
||||
"mark": "imdb",
|
||||
"score": 6.5,
|
||||
"percentage": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "x3",
|
||||
"mediaType": "tv",
|
||||
"tmdbId": 70785,
|
||||
"title": "Anne with an E",
|
||||
"requesterKey": "shared",
|
||||
"requesterName": "Shared",
|
||||
"actorKey": "mindel",
|
||||
"actorName": "Mindel",
|
||||
"stage": "Searching",
|
||||
"poster": "tv-anne-with-an-e-2017-poster.jpg",
|
||||
"ratings": [
|
||||
{
|
||||
"mark": "fresh",
|
||||
"score": 63,
|
||||
"percentage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "x4",
|
||||
"mediaType": "movie",
|
||||
"tmdbId": 99861,
|
||||
"title": "Avengers: Age of Ultron",
|
||||
"requesterKey": "justin",
|
||||
"requesterName": "Justin",
|
||||
"actorKey": "justin",
|
||||
"actorName": "Justin",
|
||||
"stage": "Waiting",
|
||||
"poster": "movie-avengers-age-of-ultron-2015-poster.jpg",
|
||||
"ratings": [
|
||||
{
|
||||
"mark": "fresh",
|
||||
"score": 75,
|
||||
"percentage": true
|
||||
},
|
||||
{
|
||||
"mark": "upright",
|
||||
"score": 82,
|
||||
"percentage": true
|
||||
},
|
||||
{
|
||||
"mark": "imdb",
|
||||
"score": 7.3,
|
||||
"percentage": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "x5",
|
||||
"mediaType": "movie",
|
||||
"tmdbId": 1033148,
|
||||
"title": "Die My Love",
|
||||
"requesterKey": "mindel",
|
||||
"requesterName": "Mindel",
|
||||
"actorKey": "mindel",
|
||||
"actorName": "Mindel",
|
||||
"stage": "Failed",
|
||||
"poster": "movie-die-my-love-2025-poster.jpg",
|
||||
"ratings": [
|
||||
{
|
||||
"mark": "fresh",
|
||||
"score": 74,
|
||||
"percentage": true
|
||||
},
|
||||
{
|
||||
"mark": "spilled",
|
||||
"score": 45,
|
||||
"percentage": true
|
||||
},
|
||||
{
|
||||
"mark": "imdb",
|
||||
"score": 6,
|
||||
"percentage": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "x6",
|
||||
"mediaType": "movie",
|
||||
"tmdbId": 416153,
|
||||
"title": "Arctic Dogs",
|
||||
"requesterKey": "shared",
|
||||
"requesterName": "Shared",
|
||||
"actorKey": "justin",
|
||||
"actorName": "Justin",
|
||||
"stage": "Ready",
|
||||
"poster": "movie-arctic-dogs-2019-poster.jpg",
|
||||
"ratings": [
|
||||
{
|
||||
"mark": "rotten",
|
||||
"score": 13,
|
||||
"percentage": true
|
||||
},
|
||||
{
|
||||
"mark": "upright",
|
||||
"score": 60,
|
||||
"percentage": true
|
||||
},
|
||||
{
|
||||
"mark": "imdb",
|
||||
"score": 4.9,
|
||||
"percentage": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "x7",
|
||||
"mediaType": "movie",
|
||||
"tmdbId": 76203,
|
||||
"title": "12 Years a Slave",
|
||||
"requesterKey": "justin",
|
||||
"requesterName": "Justin",
|
||||
"actorKey": "shared",
|
||||
"actorName": "Shared",
|
||||
"stage": "Waiting",
|
||||
"poster": "movie-12-years-a-slave-2013-poster.jpg",
|
||||
"ratings": [
|
||||
{
|
||||
"mark": "fresh",
|
||||
"score": 95,
|
||||
"percentage": true
|
||||
},
|
||||
{
|
||||
"mark": "upright",
|
||||
"score": 90,
|
||||
"percentage": true
|
||||
},
|
||||
{
|
||||
"mark": "imdb",
|
||||
"score": 8.1,
|
||||
"percentage": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "x8",
|
||||
"mediaType": "movie",
|
||||
"tmdbId": 14069,
|
||||
"title": "The Girl Who Leapt Through Time",
|
||||
"requesterKey": "mindel",
|
||||
"requesterName": "Mindel",
|
||||
"actorKey": "justin",
|
||||
"actorName": "Justin",
|
||||
"stage": "Waiting",
|
||||
"poster": "movie-the-girl-who-leapt-through-time-2006-poster.jpg",
|
||||
"ratings": [
|
||||
{
|
||||
"mark": "fresh",
|
||||
"score": 87,
|
||||
"percentage": true
|
||||
},
|
||||
{
|
||||
"mark": "upright",
|
||||
"score": 90,
|
||||
"percentage": true
|
||||
},
|
||||
{
|
||||
"mark": "imdb",
|
||||
"score": 7.6,
|
||||
"percentage": false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
|
@ -0,0 +1,52 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.justinvisser.wolfskin.design.ComingInRequest
|
||||
import org.justinvisser.wolfskin.design.ComingInSnapshot
|
||||
import org.justinvisser.wolfskin.design.ComingInStage
|
||||
import org.justinvisser.wolfskin.design.ComingInStatus
|
||||
import org.justinvisser.wolfskin.design.ShellPerson
|
||||
import org.justinvisser.wolfskin.design.ShellState
|
||||
|
||||
/** The fake clock checks refresh boundaries and cancellation without a server or a real wait. */
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class RequestsRefreshTest {
|
||||
@Test fun foregroundOpenUnsettledSettledAndStoppedTransitions() = runTest {
|
||||
val state = ShellState(listOf(ShellPerson("justin", "Justin", "J", false)), "justin")
|
||||
val request = ComingInRequest("one", "movie", 1, "One", "justin", "Justin", "shared", "Shared", ComingInStage.Waiting)
|
||||
state.requests.update(ComingInSnapshot(status = ComingInStatus.Ready, signedIn = true))
|
||||
var reads = 0
|
||||
val active = launch { refreshRequestsWhileStarted(state) { reads++ } }
|
||||
runCurrent()
|
||||
assertEquals(1, reads)
|
||||
advanceTimeBy(30_000); runCurrent()
|
||||
assertEquals("Closed and settled does not poll", 1, reads)
|
||||
state.openRequests()
|
||||
advanceTimeBy(29_999); runCurrent()
|
||||
assertEquals(1, reads)
|
||||
advanceTimeBy(1); runCurrent()
|
||||
assertEquals("An open empty sheet still refreshes at 30 seconds", 2, reads)
|
||||
state.closeRequests()
|
||||
state.requests.update(ComingInSnapshot(listOf(request), ComingInStatus.Ready, signedIn = true))
|
||||
advanceTimeBy(30_000); runCurrent()
|
||||
assertEquals("An unsettled request refreshes after the sheet closes", 3, reads)
|
||||
state.requests.update(ComingInSnapshot(listOf(request.copy(stage = ComingInStage.Ready)), ComingInStatus.Ready, signedIn = true))
|
||||
advanceTimeBy(30_000); runCurrent()
|
||||
assertEquals("Ready requests stop polling", 3, reads)
|
||||
active.cancel(); runCurrent()
|
||||
state.openRequests()
|
||||
advanceTimeBy(60_000); runCurrent()
|
||||
assertEquals("A stopped lifecycle cannot poll", 3, reads)
|
||||
val resumed = launch { refreshRequestsWhileStarted(state) { reads++ } }
|
||||
runCurrent()
|
||||
assertEquals("Returning to the foreground refreshes immediately", 4, reads)
|
||||
resumed.cancel()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import org.justinvisser.wolfskin.design.ShellState
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens
|
||||
|
||||
/** Foreground refreshes once; only an open sheet or an unsettled request keeps polling. */
|
||||
@Composable
|
||||
internal fun RequestsRefresh(state: ShellState, refresh: () -> Unit) {
|
||||
val owner = LocalLifecycleOwner.current
|
||||
LaunchedEffect(state, owner) {
|
||||
owner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
refreshRequestsWhileStarted(state, refresh)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Cancellation on leaving STARTED stops polling; reentry performs a fresh foreground read. */
|
||||
internal suspend fun refreshRequestsWhileStarted(state: ShellState, refresh: () -> Unit) {
|
||||
refresh()
|
||||
while (currentCoroutineContext().isActive) {
|
||||
delay(WolfskinTokens.Behaviour.requestsPollSeconds.toLong() * 1000L)
|
||||
if (state.requestsVisible || state.requests.snapshot.openCount > 0) refresh()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.content.Context
|
||||
import com.github.damontecres.wholphin.BuildConfig
|
||||
import org.justinvisser.wolfskin.design.ComingInSnapshot
|
||||
import org.justinvisser.wolfskin.design.ComingInStatus
|
||||
import org.justinvisser.wolfskin.design.readComingInFixture
|
||||
|
||||
/** Release stays unavailable until Conductarr sign-in supplies an authenticated generated client. */
|
||||
fun readShellRequests(context: Context): ComingInSnapshot {
|
||||
if (!BuildConfig.DEBUG) return ComingInSnapshot()
|
||||
val json = context.assets.open("wolfskin-requests.json").bufferedReader().use { it.readText() }
|
||||
val items = readComingInFixture(json) { name ->
|
||||
context.assets.open("wolfskin-requests/$name").use { it.readBytes() }
|
||||
}
|
||||
return ComingInSnapshot(items.take(6), ComingInStatus.Ready, signedIn = true)
|
||||
}
|
||||
|
|
@ -73,6 +73,7 @@ class WolfskinShellViewModel @Inject constructor(
|
|||
val seeAll: WolfskinSeeAllLists,
|
||||
) : ViewModel() {
|
||||
val backdropFlow: StateFlow<BackdropResult> = backdrop.backdropFlow
|
||||
private val requestsSnapshot by lazy { readShellRequests(context) }
|
||||
var libraryDestinations by mutableStateOf<Map<String, Destination>>(emptyMap())
|
||||
private set
|
||||
var signedInPerson by mutableStateOf("")
|
||||
|
|
@ -98,6 +99,14 @@ class WolfskinShellViewModel @Inject constructor(
|
|||
|
||||
fun clearBackdrop() { viewModelScope.launch { backdrop.clearBackdrop() } }
|
||||
|
||||
/** The debug fixture is the only Requests supplier until TV sign-in is enabled. */
|
||||
fun refreshRequests() {
|
||||
viewModelScope.launch {
|
||||
val snapshot = withContext(Dispatchers.IO) { requestsSnapshot }
|
||||
session.state?.requests?.update(snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleAction(action: ShellAction, server: JellyfinServer, user: JellyfinUser) {
|
||||
when (action) {
|
||||
is ShellAction.Destination -> if (action.route == "settings") navigation.navigateTo(Destination.Wolfskin("settings")) else navigation.replace(if (action.route == "home") Destination.Home() else Destination.Wolfskin(action.route))
|
||||
|
|
@ -105,6 +114,8 @@ class WolfskinShellViewModel @Inject constructor(
|
|||
is ShellAction.HandOff -> navigation.navigateTo(handOffDestination(action.group))
|
||||
is ShellAction.Person -> selectPerson(action.person, server, user)
|
||||
ShellAction.RetryPeople -> viewModelScope.launch { load(server, user) }
|
||||
ShellAction.RetryRequests -> refreshRequests()
|
||||
is ShellAction.RequestTitle -> navigation.navigateTo(Destination.Wolfskin("request-title/${action.request.mediaType}/${action.request.tmdbId}"))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -155,6 +166,7 @@ fun WolfskinApp(
|
|||
ShellState(listOf(ShellPerson(user.name.orEmpty().lowercase(), user.name.orEmpty(), user.name.orEmpty().take(1), false)), user.name.orEmpty().lowercase()).apply { peopleStatus = PeopleStatus.Loading }
|
||||
}
|
||||
val state = viewModel.session.state ?: loading
|
||||
RequestsRefresh(state, viewModel::refreshRequests)
|
||||
WolfskinTheme {
|
||||
CompositionLocalProvider(LocalShellState provides state) {
|
||||
Box(modifier) {
|
||||
|
|
@ -210,6 +222,7 @@ fun WolfskinDestinationContent(route: String, modifier: Modifier = Modifier) {
|
|||
when {
|
||||
route == "settings" -> SettingsList(LocalShellState.current, modifier)
|
||||
route == "discover" -> HomeDiscoverTarget(modifier)
|
||||
route.startsWith("request-title/") -> ShellPlaceholder("Title: ${LocalShellState.current.requests.focused?.title.orEmpty()} (title slice integration pending)", modifier)
|
||||
else -> ShellPlaceholder(route, modifier)
|
||||
}
|
||||
}
|
||||
|
|
|
|||