Build household requests under Settings

This commit is contained in:
justinzeus 2026-11-05 17:35:42 +01:00
parent 7a878b2d81
commit 5b0703aec5
58 changed files with 1575 additions and 23 deletions

View 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
}
]
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View file

@ -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()
}
}

View file

@ -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()
}
}

View file

@ -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)
}

View file

@ -72,6 +72,7 @@ class WolfskinShellViewModel @Inject constructor(
private val libraries: LibraryReadFacade,
) : 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("")
@ -87,6 +88,7 @@ class WolfskinShellViewModel @Inject constructor(
session.state = ShellState(people, signedInPerson) { session.onAction(it) }
}
session.onAction = { action -> handleAction(action, server, user) }
refreshRequests()
try {
libraryDestinations = libraries.readDestinations(user.id)
} catch (exception: Exception) {
@ -97,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))
@ -104,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}/${action.request.title}"))
}
}
@ -154,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) {
@ -203,5 +216,9 @@ fun WolfskinApp(
/** The single registered destination branch delegates Wolfskin-only screens back to the shell. */
@Composable
fun WolfskinDestinationContent(route: String, modifier: Modifier = Modifier) {
if (route == "settings") SettingsList(LocalShellState.current, modifier) else ShellPlaceholder(route, modifier)
when {
route == "settings" -> SettingsList(LocalShellState.current, modifier)
route.startsWith("request-title/") -> ShellPlaceholder("Title: ${route.substringAfterLast('/')} (title slice integration pending)", modifier)
else -> ShellPlaceholder(route, modifier)
}
}