Integrate shared offline recovery and saved-state isolation
All checks were successful
review/design-claude 13 fixes passed Sonnet re-review at cd625b42; merged head 84453040 = main merge + shared RecoveryToast reuse: 33/34 pairs byte-identical, st-048 adopts the shared toast, 830 tests green (firstmate check)
All checks were successful
review/design-claude 13 fixes passed Sonnet re-review at cd625b42; merged head 84453040 = main merge + shared RecoveryToast reuse: 33/34 pairs byte-identical, st-048 adopts the shared toast, 830 tests green (firstmate check)
This commit is contained in:
commit
844530404d
36 changed files with 875 additions and 97 deletions
|
|
@ -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())
|
||||
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())
|
||||
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())
|
||||
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())
|
||||
}
|
||||
|
||||
@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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,8 @@ import android.app.Application
|
|||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.performKeyInput
|
||||
import androidx.compose.ui.test.pressKey
|
||||
|
|
@ -21,6 +23,8 @@ import org.junit.Rule
|
|||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.justinvisser.wolfskin.design.ShellPerson
|
||||
import org.justinvisser.wolfskin.design.RecoveryMessage
|
||||
import org.justinvisser.wolfskin.design.TitleConnection
|
||||
import org.justinvisser.wolfskin.design.ShellState
|
||||
import org.justinvisser.wolfskin.design.TitleItem
|
||||
import org.justinvisser.wolfskin.design.TitlePageData
|
||||
|
|
@ -122,5 +126,40 @@ class TitleAsyncFocusTest {
|
|||
assertFalse(shell.navFocused)
|
||||
}
|
||||
|
||||
@Test fun failedRetryKeepsThePersonsSavedTitleAndShowsSharedRecovery() {
|
||||
val id = UUID.fromString("00000000-0000-0000-0000-000000000014")
|
||||
val reads = java.util.concurrent.atomic.AtomicInteger()
|
||||
val saved = TitlePageData(TitleItem(id.toString(), "Saved title", "movie"),
|
||||
connection = TitleConnection.Offline, savedAt = "20:42")
|
||||
val facade = mockk<TitleReadFacade>()
|
||||
coEvery { facade.readSaved(id, any()) } returns saved
|
||||
coEvery { facade.read(id, any(), any()) } coAnswers {
|
||||
reads.incrementAndGet()
|
||||
throw java.io.IOException("offline")
|
||||
}
|
||||
viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk<TitleFixtureProvider>(), mockk<NavigationManager>(relaxed = true))
|
||||
val viewer = ShellPerson("justin", "Justin", "J", false)
|
||||
val route = titleRoute(id.toString())
|
||||
val shell = ShellState(listOf(viewer), viewer.key).apply {
|
||||
pickerVisible = false; pickerLaunch = false; enterDestination(route)
|
||||
}
|
||||
rule.setContent { WolfskinTheme {
|
||||
TitleDestination(route, shell, UserPreferences(AppPreferences.getDefaultInstance(), null), viewModel = viewModel)
|
||||
} }
|
||||
rule.waitUntil(timeoutMillis = 5000) { reads.get() == 1 && viewModel.title?.toast != null }
|
||||
rule.waitForIdle()
|
||||
assertEquals("20:42", shell.navSavedAt)
|
||||
assertEquals("retry", viewModel.title!!.options[viewModel.title!!.column].id)
|
||||
rule.runOnIdle { viewModel.title!!.toast = null }
|
||||
press(Key.DirectionCenter)
|
||||
rule.waitUntil(timeoutMillis = 5000) { reads.get() == 2 && viewModel.title?.toast != null }
|
||||
rule.waitForIdle()
|
||||
assertEquals("Saved title", viewModel.title!!.data.item.name)
|
||||
assertEquals("20:42", shell.navSavedAt)
|
||||
assertEquals(RecoveryMessage.StillUnreachable, viewModel.title!!.toast)
|
||||
assertFalse(shell.navFocused)
|
||||
rule.onNodeWithTag("recovery-toast").assertIsDisplayed()
|
||||
}
|
||||
|
||||
private fun press(key: Key) { rule.onRoot().performKeyInput { pressKey(key) } }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import com.github.damontecres.wholphin.WholphinApplication
|
|||
import com.github.damontecres.wholphin.data.ItemPlaybackRepository
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||
|
|
@ -141,6 +142,28 @@ class TitleReadFacadeTest {
|
|||
assertEquals("A real file", saved.credits.single().name)
|
||||
}
|
||||
|
||||
@Test fun personSavedCopiesNeverCrossServerOrViewerIdentity() = runTest {
|
||||
val userA = mockk<JellyfinUser> { every { id } returns firstId }
|
||||
val userB = mockk<JellyfinUser> { every { id } returns secondId }
|
||||
val serverA = mockk<JellyfinServer> { every { id } returns seriesId }
|
||||
val serverB = mockk<JellyfinServer> { every { id } returns seasonId }
|
||||
every { repository.currentUser } returns userA
|
||||
every { repository.currentServer } returns serverA
|
||||
coEvery { library.getItem(firstId) } returns successResponse(
|
||||
BaseItemDto(id = firstId, type = BaseItemKind.PERSON, name = "Saved actor", overview = "Private biography"))
|
||||
coEvery { items.getItems(any<GetItemsRequest>()) } returns successQueryResult(listOf(file(secondId)))
|
||||
facade.readPerson(firstId, 0)
|
||||
every { repository.currentUser } returns userB
|
||||
assertNull(facade.readPersonSaved(firstId))
|
||||
every { repository.currentUser } returns userA
|
||||
every { repository.currentServer } returns serverB
|
||||
assertNull(facade.readPersonSaved(firstId))
|
||||
every { repository.currentServer } returns serverA
|
||||
val saved = requireNotNull(facade.readPersonSaved(firstId))
|
||||
assertEquals("Private biography", saved.person.biography)
|
||||
assertEquals(secondId.toString(), saved.credits.single().id)
|
||||
}
|
||||
|
||||
@Test fun explicitEpisodeStaysThePlayAndWatchTargetAcrossDifferentSeasonSelection() = runTest {
|
||||
val episode = file(firstId, BaseItemKind.EPISODE).copy(seriesId = seriesId, parentIndexNumber = 4, indexNumber = 9)
|
||||
val other = file(secondId, BaseItemKind.EPISODE).copy(seriesId = seriesId, parentIndexNumber = 2, indexNumber = 1)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -42,27 +39,43 @@ class WolfskinHomeViewModel @Inject constructor(
|
|||
private set
|
||||
var toast by mutableStateOf<String?>(null)
|
||||
private set
|
||||
private var sourceScope: String? = null
|
||||
private var loadRevision = 0L
|
||||
|
||||
suspend fun load(server: JellyfinServer, user: JellyfinUser, person: String, retry: Boolean = false) {
|
||||
withContext(Dispatchers.IO) { debugHomeSnapshot(context, person) }?.let { snapshot = it; return }
|
||||
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
|
||||
val continuation = listOf(homeDiscoverState(HomeAvailability.SignedOut))
|
||||
if (person != user.name.orEmpty().lowercase()) {
|
||||
snapshot = HomeSnapshot(buildHomeRows(HomeLibrary(), continuation, 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
|
||||
if (saved != null) snapshot = HomeSnapshot(buildHomeRows(saved.first, continuation))
|
||||
try {
|
||||
val library = withContext(Dispatchers.IO) { home.read(user.id) }
|
||||
val savedAt = System.currentTimeMillis()
|
||||
withContext(Dispatchers.IO) { cache.write(scope, library, savedAt) }
|
||||
withContext(Dispatchers.IO) { try { cache.write(scope, library, savedAt) } catch (_: java.io.IOException) { /* Local storage failure must not turn a reachable library into an outage. */ } }
|
||||
if (revision != loadRevision) return
|
||||
snapshot = HomeSnapshot(buildHomeRows(library, continuation))
|
||||
} catch (exception: Exception) {
|
||||
if (exception is CancellationException) throw exception
|
||||
if (revision != loadRevision) return
|
||||
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.")
|
||||
if (retry) showToast(RecoveryMessage.StillUnreachable)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -74,16 +87,16 @@ class WolfskinHomeViewModel @Inject constructor(
|
|||
is HomeAction.Details -> navigation.navigateTo(Destination.Wolfskin(titleRoute(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.")
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -126,8 +139,6 @@ fun HomeDestination(shell: ShellState, server: JellyfinServer, user: JellyfinUse
|
|||
LaunchedEffect(shell.person.key, server.id, user.id) { model.load(server, user, shell.person.key) }
|
||||
Box(modifier) {
|
||||
HomeScreen(shell, model.snapshot, 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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import kotlinx.coroutines.launch
|
|||
import kotlinx.coroutines.withContext
|
||||
import org.justinvisser.wolfskin.design.PersonState
|
||||
import org.justinvisser.wolfskin.design.PersonSurface
|
||||
import org.justinvisser.wolfskin.design.RecoveryMessage
|
||||
import org.justinvisser.wolfskin.design.ShellPerson
|
||||
import org.justinvisser.wolfskin.design.ShellState
|
||||
import org.justinvisser.wolfskin.design.TitleAction
|
||||
|
|
@ -102,7 +103,7 @@ class WolfskinTitleViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private suspend fun readTitle(id: String) {
|
||||
private suspend fun readTitle(id: String, retry: Boolean = false) {
|
||||
val uuid = id.toUuidOrNull() ?: return
|
||||
val current = title ?: return
|
||||
try {
|
||||
|
|
@ -113,12 +114,12 @@ class WolfskinTitleViewModel @Inject constructor(
|
|||
if (exception is CancellationException) throw exception
|
||||
if (current.data.status == TitleReadStatus.Ready) {
|
||||
if (current.data.episodesStatus == TitleReadStatus.Loading) current.restoreSeason()
|
||||
current.toast = "Still can’t reach it. The last saved view stays on screen."
|
||||
} else current.update(current.data.copy(status = TitleReadStatus.Unavailable))
|
||||
if (current.data.status == TitleReadStatus.Ready || retry) current.toast = RecoveryMessage.StillUnreachable
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun readPerson(id: String) {
|
||||
private suspend fun readPerson(id: String, retry: Boolean = false) {
|
||||
if (personReadPending || personReadFinished) return
|
||||
val uuid = id.toUuidOrNull() ?: return
|
||||
val owner = viewer.key
|
||||
|
|
@ -137,8 +138,11 @@ class WolfskinTitleViewModel @Inject constructor(
|
|||
} catch (exception: Exception) {
|
||||
if (exception is CancellationException) throw exception
|
||||
if (owner == viewer.key) {
|
||||
person?.toast = "Still can’t reach it. The last saved view stays on screen."
|
||||
if (person == null) title?.update(title!!.data.copy(status = TitleReadStatus.Unavailable))
|
||||
person?.toast = RecoveryMessage.StillUnreachable
|
||||
if (person == null) {
|
||||
title?.update(title!!.data.copy(status = TitleReadStatus.Unavailable))
|
||||
if (retry) title?.toast = RecoveryMessage.StillUnreachable
|
||||
}
|
||||
}
|
||||
} finally { if (owner == viewer.key) personReadPending = false }
|
||||
}
|
||||
|
|
@ -175,9 +179,9 @@ class WolfskinTitleViewModel @Inject constructor(
|
|||
}
|
||||
TitleAction.Retry -> viewModelScope.launch {
|
||||
if (fixture == null) {
|
||||
if (route.startsWith("person/")) readPerson(Uri.decode(route.substringAfter('/'))) else readTitle(Uri.decode(route.substringAfter('/')))
|
||||
if (route.startsWith("person/")) readPerson(Uri.decode(route.substringAfter('/')), retry = true) else readTitle(Uri.decode(route.substringAfter('/')), retry = true)
|
||||
}
|
||||
else title?.toast = "Still can’t reach it. The last saved view stays on screen."
|
||||
else title?.toast = RecoveryMessage.StillUnreachable
|
||||
}
|
||||
is TitleAction.Request, is TitleAction.Requests -> title?.toast = "Requests need this TV to be signed in to Conductarr."
|
||||
is TitleAction.Hide -> if (fixture != null) fixtureSelections.edit().putBoolean("${viewer.key}:${action.item.id}:hidden", action.hidden).apply()
|
||||
|
|
@ -192,7 +196,7 @@ class WolfskinTitleViewModel @Inject constructor(
|
|||
navigation.navigateTo(Destination.Playback(id, position))
|
||||
} catch (exception: Exception) {
|
||||
if (exception is CancellationException) throw exception
|
||||
title?.toast = "Can’t reach Jellyfin to play. Check the network and try again."
|
||||
title?.toast = RecoveryMessage.PlaybackUnavailable
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -231,11 +235,11 @@ class WolfskinTitleViewModel @Inject constructor(
|
|||
@Composable
|
||||
fun TitleDestination(route: String, shell: ShellState, preferences: UserPreferences, modifier: Modifier = Modifier, viewModel: WolfskinTitleViewModel = hiltViewModel()) {
|
||||
LaunchedEffect(route, shell.person.key) { viewModel.load(route, shell.person, preferences) }
|
||||
val title = viewModel.title
|
||||
val title = viewModel.title?.takeIf { it.person.key == shell.person.key }
|
||||
LaunchedEffect(title?.data?.status) {
|
||||
if (title?.data?.status == TitleReadStatus.Ready) shell.navFocused = false
|
||||
}
|
||||
val person = viewModel.person
|
||||
val person = viewModel.person.takeIf { title != null }
|
||||
BackHandler(enabled = title != null) {
|
||||
title?.back(shell)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ import javax.inject.Singleton
|
|||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.justinvisser.wolfskin.design.PeopleStatus
|
||||
import org.justinvisser.wolfskin.design.SettingsList
|
||||
|
|
@ -102,10 +103,17 @@ 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() {
|
||||
fun refreshRequests() = refreshRequests(retry = false)
|
||||
|
||||
private fun refreshRequests(retry: Boolean) {
|
||||
val shell = session.state ?: return
|
||||
val target = shell.requests
|
||||
viewModelScope.launch {
|
||||
val snapshot = withContext(Dispatchers.IO) { requestsSnapshot }
|
||||
session.state?.requests?.update(snapshot)
|
||||
target.update(snapshot)
|
||||
if (retry && snapshot.status == org.justinvisser.wolfskin.design.ComingInStatus.Unavailable && shell.requests === target) {
|
||||
shell.toast = org.justinvisser.wolfskin.design.RecoveryMessage.StillUnreachable
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -116,7 +124,7 @@ 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()
|
||||
ShellAction.RetryRequests -> refreshRequests(retry = true)
|
||||
is ShellAction.RequestTitle -> navigation.navigateTo(Destination.Wolfskin(titleRoute("${action.request.mediaType}:${action.request.tmdbId}")))
|
||||
}
|
||||
}
|
||||
|
|
@ -205,14 +213,15 @@ fun WolfskinApp(
|
|||
BackHandler { state.key(org.justinvisser.wolfskin.design.ShellKey.Back) }
|
||||
val upstream = resolveUpstreamDestination(destination, WolfskinScreenPreference.enabled, viewModel.libraryDestinations)
|
||||
if (upstream == null && (route.startsWith("title/") || route.startsWith("person/"))) {
|
||||
if (destination is Destination.MediaItem && state.person.key != viewModel.signedInPerson) {
|
||||
val jellyfinIdentity = destination is Destination.MediaItem || android.net.Uri.decode(route.substringAfter('/')).toUUIDOrNull() != null
|
||||
if (jellyfinIdentity && state.person.key != viewModel.signedInPerson) {
|
||||
ShellSurface(state, handlesContentKeys = false) { ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", it) }
|
||||
} else TitleDestination(route, state, preferences)
|
||||
} else {
|
||||
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/") ->
|
||||
|
|
|
|||
|
|
@ -82,6 +82,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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue