Integrate native Home and replay full title launch paths
This commit is contained in:
commit
fc02ce8646
697 changed files with 2956 additions and 146 deletions
|
|
@ -0,0 +1,8 @@
|
|||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.content.Context
|
||||
import org.justinvisser.wolfskin.design.HomeFixture
|
||||
import org.justinvisser.wolfskin.design.HomeSnapshot
|
||||
|
||||
/** Only debug builds may read the locked prototype household projection. */
|
||||
fun debugHomeSnapshot(context: Context, person: String): HomeSnapshot? = HomeFixture(context).snapshot(person)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.content.Context
|
||||
import org.justinvisser.wolfskin.design.HomeSnapshot
|
||||
|
||||
/** Release Home always reads the selected Jellyfin identity, never prototype data. */
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
fun debugHomeSnapshot(context: Context, person: String): HomeSnapshot? = null
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.app.Application
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.justinvisser.wolfskin.design.HomeLibrary
|
||||
import org.justinvisser.wolfskin.design.HomeTitle
|
||||
import org.justinvisser.wolfskin.tv.read.HomeLibraryCache
|
||||
|
||||
/** A reopened Home cache preserves playable identity and episode/resume state without network reads. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class)
|
||||
class HomeLibraryCacheTest {
|
||||
@Test fun savedHomeReopensWithEpisodeMetadataAndFullShelfCount() {
|
||||
val context = ApplicationProvider.getApplicationContext<Application>()
|
||||
val episode = HomeTitle("episode-id", "The next episode", "episode", year = 2025, runtimeSeconds = 1800, overview = "Overview", poster = "poster", backdrop = "backdrop", seriesTitle = "A series", season = 2, episode = 3, resumeSeconds = 125.5, addedAt = "2026-11-05", genres = listOf("Drama"), held = true, tmdbId = 42)
|
||||
val film = HomeTitle("film-id", "A film", "movie", year = 2024, runtimeSeconds = 5400, played = true, held = true, tmdbId = 43)
|
||||
val library = HomeLibrary(listOf(episode), listOf(episode.copy(id = "next", resumeSeconds = 0.0)), listOf(film), mapOf("movies" to listOf(film)), mapOf("movies" to 84))
|
||||
HomeLibraryCache(context).write("server:person", library, 12345L)
|
||||
val saved = HomeLibraryCache(context).read("server:person")!!
|
||||
assertEquals(library, saved.first)
|
||||
assertEquals(12345L, saved.second)
|
||||
assertNull(HomeLibraryCache(context).read("server:someone-else"))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.app.Application
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performKeyInput
|
||||
import androidx.compose.ui.test.pressKey
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import io.mockk.mockk
|
||||
import java.util.UUID
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.justinvisser.wolfskin.design.*
|
||||
import org.justinvisser.wolfskin.tv.read.LibraryReadFacade
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/** Home opens the complete shared grid and Back restores its original continuation card. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
class HomeSeeAllNavigationTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test fun personalMovieContinuationReachesTheEighteenthTitleAndReturnsToHome() = continuation("pk-m", 5) { grid ->
|
||||
assertEquals(18, grid.items.size)
|
||||
repeat(2) { press(Key.DirectionDown) }
|
||||
repeat(5) { press(Key.DirectionRight) }
|
||||
rule.onNodeWithText("Bleach the Movie: Fade to Black").assertIsDisplayed()
|
||||
assertEquals("movie-bleach-the-movie-fade-to-black-2008", grid.focusedTitle?.id)
|
||||
}
|
||||
|
||||
@Test fun horizontallyScrolledContinuationRestoresTheExactHomeViewport() = continuation("trending", 20) { grid ->
|
||||
press(Key.DirectionDown)
|
||||
assertEquals(6, grid.index)
|
||||
rule.onNodeWithTag("library-grid").assertIsDisplayed()
|
||||
}
|
||||
|
||||
private fun continuation(rowId: String, expectedColumn: Int, exerciseGrid: (LibraryGridState) -> Unit) {
|
||||
rule.mainClock.autoAdvance = false
|
||||
val snapshot = HomeFixture(rule.activity).snapshot("justin")
|
||||
val row = snapshot.rows.first { it.id == rowId }
|
||||
val cardTag = "home-card:$rowId:$expectedColumn"
|
||||
val navigation = NavigationManager()
|
||||
val lists = WolfskinSeeAllLists()
|
||||
val shell = ShellState(listOf(ShellPerson("justin", "Justin", "J", true)), "justin") {
|
||||
if (it == ShellAction.Pop) navigation.goBack()
|
||||
}.apply {
|
||||
pickerVisible = false
|
||||
view.row = snapshot.rows.indexOf(row)
|
||||
view.home.focusedKey = row.id
|
||||
view.cards[row.id] = row.cards.size
|
||||
}
|
||||
val server = JellyfinServer(UUID.fromString("00000000-0000-0000-0000-000000000001"), "Fixture", "https://example.invalid", null)
|
||||
val user = JellyfinUser(id = UUID.fromString("00000000-0000-0000-0000-000000000002"), name = "Justin", serverId = server.id, accessToken = null)
|
||||
val model = WolfskinHomeViewModel(rule.activity, mockk(), mockk(), navigation, lists)
|
||||
val facade = mockk<LibraryReadFacade>()
|
||||
rule.setContent {
|
||||
val destination = navigation.backStack.last()
|
||||
val route = (destination as? Destination.Wolfskin)?.route ?: "home"
|
||||
LaunchedEffect(destination) { shell.enterDestination(route) }
|
||||
WolfskinTheme { ShellSurface(shell) { modifier ->
|
||||
if (destination is Destination.Home) HomeScreen(shell, snapshot, modifier) { model.act(it, shell, server, user) }
|
||||
else WolfskinLibraryContent(rule.activity, route, shell, user, "fixture", facade, navigation, lists, modifier)
|
||||
} }
|
||||
}
|
||||
settle()
|
||||
rule.onNodeWithTag(cardTag).assertIsDisplayed()
|
||||
val home = shell.view
|
||||
val track = home.home.tracks.getValue(rowId)
|
||||
val verticalIndex = home.listState.firstVisibleItemIndex
|
||||
val verticalOffset = home.listState.firstVisibleItemScrollOffset
|
||||
val rowOffset = home.listState.layoutInfo.visibleItemsInfo.first { it.key == rowId }.offset
|
||||
val horizontalIndex = track.firstVisibleItemIndex
|
||||
val horizontalOffset = track.firstVisibleItemScrollOffset
|
||||
press(Key.DirectionCenter)
|
||||
assertEquals("seeall/justin/$rowId", (navigation.backStack.last() as Destination.Wolfskin).route)
|
||||
val grid = requireNotNull(lists.get("justin/$rowId"))
|
||||
try {
|
||||
rule.onNodeWithTag("library-grid").assertIsDisplayed()
|
||||
exerciseGrid(grid)
|
||||
press(Key.Back)
|
||||
rule.waitForIdle()
|
||||
assertTrue(navigation.backStack.last() is Destination.Home)
|
||||
assertEquals(row.id, snapshot.rows[shell.view.row].id)
|
||||
assertEquals(expectedColumn, shell.view.cards[row.id])
|
||||
assertSame("Home retains its original route memory", home, shell.view)
|
||||
assertSame("Home retains its original horizontal state", track, home.home.tracks.getValue(rowId))
|
||||
assertEquals("Vertical first item", verticalIndex, home.listState.firstVisibleItemIndex)
|
||||
assertEquals("Vertical pixel offset", verticalOffset, home.listState.firstVisibleItemScrollOffset)
|
||||
assertEquals("Selected row pixel offset", rowOffset, home.listState.layoutInfo.visibleItemsInfo.first { it.key == rowId }.offset)
|
||||
assertEquals("Horizontal first item", horizontalIndex, track.firstVisibleItemIndex)
|
||||
assertEquals("Horizontal pixel offset", horizontalOffset, track.firstVisibleItemScrollOffset)
|
||||
rule.onNodeWithTag(cardTag).assertIsDisplayed()
|
||||
} catch (failure: AssertionError) {
|
||||
throw AssertionError(
|
||||
"Expected $rowId:$expectedColumn vertical=$verticalIndex/$verticalOffset row-offset=$rowOffset horizontal=$horizontalIndex/$horizontalOffset; " +
|
||||
"actual route=${shell.route} row=${home.row} card=${home.cards[rowId]} nav=${shell.navFocused} handler=${shell.contentFocus?.javaClass?.simpleName} grid=${grid.index} " +
|
||||
"vertical=${home.listState.firstVisibleItemIndex}/${home.listState.firstVisibleItemScrollOffset} horizontal=${track.firstVisibleItemIndex}/${track.firstVisibleItemScrollOffset} " +
|
||||
"visible-rows=${home.listState.layoutInfo.visibleItemsInfo.map { it.key to it.offset }}",
|
||||
failure,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun press(key: Key) {
|
||||
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) }
|
||||
settle()
|
||||
}
|
||||
|
||||
private fun settle() {
|
||||
repeat(3) {
|
||||
rule.mainClock.advanceTimeByFrame()
|
||||
rule.waitForIdle()
|
||||
}
|
||||
rule.mainClock.advanceTimeBy(1_000)
|
||||
rule.waitForIdle()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.app.Application
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.performKeyInput
|
||||
import androidx.compose.ui.test.pressKey
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.justinvisser.wolfskin.design.*
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/** Full launch paths render the real picker, Home hero/shelves, title and person surfaces. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
class TitleLaunchKeysTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
private lateinit var fixture: TitleFixture
|
||||
private lateinit var home: HomeFixture
|
||||
private lateinit var shell: ShellState
|
||||
private var title by mutableStateOf<TitleState?>(null)
|
||||
private var person by mutableStateOf<PersonState?>(null)
|
||||
private val actions = mutableListOf<TitleAction>()
|
||||
private val shellActions = mutableListOf<ShellAction>()
|
||||
private lateinit var paths: List<JSONObject>
|
||||
|
||||
@Before fun launch() {
|
||||
fixture = TitleFixture(rule.activity.assets.open("wolfskin-titles.json").bufferedReader().use { JSONObject(it.readText()) })
|
||||
home = HomeFixture(rule.activity)
|
||||
val source = javaClass.getResourceAsStream("/title-press-paths.json")!!.bufferedReader().use { JSONArray(it.readText()) }
|
||||
paths = List(source.length()) { source.getJSONObject(it) }
|
||||
shell = ShellState(fixture.people, "justin") { action ->
|
||||
shellActions.add(action)
|
||||
if (action is ShellAction.Person) { title = null; person = null }
|
||||
}
|
||||
rule.setContent { WolfskinTheme {
|
||||
val performer = person
|
||||
val page = title
|
||||
val snapshot = remember(shell.person.key) { home.snapshot(shell.person.key) }
|
||||
when {
|
||||
performer != null -> PersonSurface(performer, shell)
|
||||
page != null -> TitleSurface(page, shell)
|
||||
else -> ShellSurface(shell) { modifier ->
|
||||
HomeScreen(shell, snapshot, modifier) { action ->
|
||||
if (action is HomeAction.Details) openTitle(action.title.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
} }
|
||||
}
|
||||
|
||||
private fun openTitle(id: String) {
|
||||
val data = fixture.title(id, shell.person.key)
|
||||
title = TitleState(data, shell.person, ::act)
|
||||
person = null
|
||||
shell.enterDestination(titleRoute(id))
|
||||
}
|
||||
|
||||
private fun act(action: TitleAction) {
|
||||
actions.add(action)
|
||||
when (action) {
|
||||
is TitleAction.Person -> {
|
||||
val (actor, credits) = fixture.person(action.person.id, shell.person.key)
|
||||
person = PersonState(actor, credits, ::act)
|
||||
shell.enterDestination(personRoute(actor.id))
|
||||
}
|
||||
is TitleAction.OpenTitle -> openTitle(action.item.id)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private fun replay(end: String, expectedPresses: Int) {
|
||||
val path = paths.single { it.getString("end") == end }
|
||||
val keys = path.getJSONArray("keys")
|
||||
assertEquals(expectedPresses, path.getInt("presses"))
|
||||
assertEquals(expectedPresses, keys.length())
|
||||
for (index in 0 until keys.length()) {
|
||||
val key = when (keys.getString(index)) {
|
||||
"UP" -> Key.DirectionUp; "DOWN" -> Key.DirectionDown
|
||||
"LEFT" -> Key.DirectionLeft; "RIGHT" -> Key.DirectionRight
|
||||
"OK" -> Key.DirectionCenter
|
||||
else -> error("Unknown launch replay key")
|
||||
}
|
||||
rule.onRoot().performKeyInput { pressKey(key) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun titleToNavTakesFourPressesFromTheRealHomeHero() {
|
||||
replay("nav", 4)
|
||||
assertNotNull(title)
|
||||
assertTrue(shell.navFocused)
|
||||
assertEquals(shell.currentTab, shell.navIndex)
|
||||
}
|
||||
|
||||
@Test fun switchingPersonFromATitleTakesEightLaunchPresses() {
|
||||
replay("person-mindel", 8)
|
||||
assertEquals("mindel", shell.person.key)
|
||||
assertFalse(shell.pickerVisible)
|
||||
assertEquals("home", shell.route)
|
||||
assertTrue(shellActions.last() is ShellAction.Person)
|
||||
}
|
||||
|
||||
@Test fun lockeVersionTwoAndPlayTakesFifteenLaunchPresses() {
|
||||
replay("play-version-2", 15)
|
||||
val page = requireNotNull(title)
|
||||
assertEquals("Locke", page.data.item.name)
|
||||
assertEquals("v2", page.versionId)
|
||||
assertNull(page.sheet)
|
||||
assertEquals("play", page.options[page.column].id)
|
||||
assertEquals("Locke", (actions.last() as TitleAction.Play).item.name)
|
||||
}
|
||||
|
||||
@Test fun lockeToTomHardyTakesElevenLaunchPresses() {
|
||||
replay("person-Tom Hardy", 11)
|
||||
assertEquals("Tom Hardy", requireNotNull(person).person.name)
|
||||
assertEquals(0, person!!.index)
|
||||
assertFalse(shell.navFocused)
|
||||
assertTrue(actions.last() is TitleAction.Person)
|
||||
}
|
||||
}
|
||||
|
|
@ -18,11 +18,13 @@ import androidx.compose.ui.graphics.Color
|
|||
import androidx.compose.ui.graphics.toPixelMap
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertIsFocused
|
||||
import androidx.compose.ui.test.assertIsNotFocused
|
||||
import androidx.compose.ui.test.captureToImage
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.performKeyInput
|
||||
import androidx.compose.ui.test.pressKey
|
||||
|
|
@ -131,6 +133,7 @@ class UpstreamContentFocusTest {
|
|||
rule.runOnIdle { WolfskinScreenPreference.enabled = true }
|
||||
rule.mainClock.advanceTimeBy(1500)
|
||||
rule.waitForIdle()
|
||||
rule.onNodeWithText("Sign in as Justin to open their Jellyfin library").assertIsDisplayed()
|
||||
val on = rule.onRoot().captureToImage().toPixelMap()[1700, 900]
|
||||
val background = org.justinvisser.wolfskin.design.WolfskinTokens.Color.bg
|
||||
org.junit.Assert.assertEquals("Owned background red", background.red, on.red, 1f / 255f)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,133 @@
|
|||
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
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.justinvisser.wolfskin.design.*
|
||||
import org.justinvisser.wolfskin.tv.read.HomeLibraryCache
|
||||
import org.justinvisser.wolfskin.tv.read.HomeReadFacade
|
||||
|
||||
/** The Home entry owns its source reads and upstream navigation, never the design module. */
|
||||
@HiltViewModel
|
||||
class WolfskinHomeViewModel @Inject constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val home: HomeReadFacade,
|
||||
private val cache: HomeLibraryCache,
|
||||
private val navigation: NavigationManager,
|
||||
private val seeAll: WolfskinSeeAllLists,
|
||||
) : ViewModel() {
|
||||
var snapshot by mutableStateOf(HomeSnapshot(buildHomeRows(HomeLibrary(), emptyList(), loading = true), HomeAvailability.Loading))
|
||||
private set
|
||||
var toast by mutableStateOf<String?>(null)
|
||||
private set
|
||||
|
||||
suspend fun load(server: JellyfinServer, user: JellyfinUser, person: String, retry: Boolean = false) {
|
||||
withContext(Dispatchers.IO) { debugHomeSnapshot(context, person) }?.let { snapshot = it; return }
|
||||
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 (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) }
|
||||
snapshot = HomeSnapshot(buildHomeRows(library, continuation))
|
||||
} 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.")
|
||||
}
|
||||
}
|
||||
|
||||
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.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.")
|
||||
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.")
|
||||
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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun openRoot(shell: ShellState, route: String) {
|
||||
shell.enterDestination(route)
|
||||
shell.focusNav(shell.tabs.indexOf(route))
|
||||
shell.key(ShellKey.Ok)
|
||||
}
|
||||
|
||||
private fun showToast(message: String) {
|
||||
toast = message
|
||||
viewModelScope.launch { delay(WolfskinTokens.Motion.toast.toLong()); if (toast == message) toast = null }
|
||||
}
|
||||
}
|
||||
|
||||
/** Complete catalogue projections feed the shared See all grid without another source read. */
|
||||
internal fun HomeTitle.libraryTitle(): LibraryTitle = LibraryTitle(
|
||||
id = id, title = title, type = type, year = year, runtimeSeconds = runtimeSeconds?.toLong(), seasons = seasons,
|
||||
poster = poster, played = played, progress = runtimeSeconds?.let { if (it > 0) (resumeSeconds / it).toFloat() else 0f } ?: 0f,
|
||||
addedAt = addedAt, tmdbId = tmdbId?.toString(),
|
||||
availability = if (held) "In your library" else otherOwner?.let { "In $it’s library" }.orEmpty(),
|
||||
badge = if (held) "held" else null,
|
||||
ratings = ratings.map { rating ->
|
||||
when (rating.mark) {
|
||||
"fresh" -> LibraryRating("rtCritics", rating.score, "Fresh")
|
||||
"rotten" -> LibraryRating("rtCritics", rating.score, "Rotten")
|
||||
"tomato" -> LibraryRating("rtCritics", rating.score)
|
||||
"upright" -> LibraryRating("rtAudience", rating.score, "Upright")
|
||||
"spilled" -> LibraryRating("rtAudience", rating.score, "Spilled")
|
||||
else -> LibraryRating(rating.mark, rating.score)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
/** The shell keeps its focus memory while playback or later-slice screens are pushed above Home. */
|
||||
@Composable
|
||||
fun HomeDestination(shell: ShellState, server: JellyfinServer, user: JellyfinUser, modifier: Modifier = Modifier, model: WolfskinHomeViewModel = hiltViewModel()) {
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -212,8 +212,9 @@ fun WolfskinApp(
|
|||
ShellSurface(state, handlesContentKeys = upstream == null,
|
||||
overlay = { if (route == "discover" || route.startsWith("discover-")) DiscoverOverlay(discover) }) { contentModifier ->
|
||||
when {
|
||||
upstream != null && state.person.key != viewModel.signedInPerson -> ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", contentModifier)
|
||||
(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 -> DestinationContent(upstream, preferences, viewModel::clearBackdrop, contentModifier)
|
||||
destination is Destination.Home -> HomeDestination(state, server, user, contentModifier)
|
||||
route in LIBRARY_DESTINATIONS || route.startsWith("seeall/") ->
|
||||
WolfskinLibraryContent(androidx.compose.ui.platform.LocalContext.current, route, state, user, "${server.id}:${user.id}", viewModel.libraries, navigationManager, viewModel.seeAll, contentModifier)
|
||||
destination is Destination.Wolfskin -> DestinationContent(destination, preferences, viewModel::clearBackdrop, contentModifier)
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ fun WolfskinLibraryContent(
|
|||
seeAll: WolfskinSeeAllLists,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val view = shell.view
|
||||
val view = shell.rememberedView(route)
|
||||
val fixture by produceState<LibraryFixture?>(null, context) {
|
||||
if (BuildConfig.DEBUG) value = withContext(Dispatchers.IO) {
|
||||
context.assets.open("wolfskin-library.json").bufferedReader().use { LibraryFixture(it.readText(), "file:///android_asset/library/") }
|
||||
|
|
@ -87,7 +87,7 @@ fun WolfskinLibraryContent(
|
|||
if (wasLoading && shell.navFocused && shell.navIndex == shell.currentTab) shell.navFocused = false
|
||||
}
|
||||
}
|
||||
LibraryScreen(shell, grid, modifier, ratingsSignedIn = fixture != null)
|
||||
LibraryScreen(shell, grid, modifier, ratingsSignedIn = fixture != null, view = view)
|
||||
}
|
||||
|
||||
val LIBRARY_DESTINATIONS = setOf("movies", "shows", "anime")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
package org.justinvisser.wolfskin.tv.read
|
||||
|
||||
import android.content.Context
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
import org.justinvisser.wolfskin.conductarr.CacheEntry
|
||||
import org.justinvisser.wolfskin.conductarr.FileCacheStore
|
||||
import org.justinvisser.wolfskin.design.HomeLibrary
|
||||
import org.justinvisser.wolfskin.design.HomeTitle
|
||||
|
||||
/** Saved Jellyfin projections reuse the app-private atomic cache and are partitioned by server/user. */
|
||||
@Singleton
|
||||
class HomeLibraryCache @Inject constructor(@param:ApplicationContext context: Context) {
|
||||
private val store = FileCacheStore(File(context.filesDir, "wolfskin-home"))
|
||||
|
||||
fun read(scope: String): Pair<HomeLibrary, Long>? {
|
||||
val entry = store.read(scope, KEY) ?: return null
|
||||
return try {
|
||||
val json = JSONObject(entry.body)
|
||||
val destinations = json.getJSONObject("destinations")
|
||||
val totals = json.getJSONObject("totals")
|
||||
HomeLibrary(
|
||||
decode(json.getJSONArray("resume")), decode(json.getJSONArray("nextUp")), decode(json.getJSONArray("newest")),
|
||||
destinations.keys().asSequence().associateWith { decode(destinations.getJSONArray(it)) },
|
||||
totals.keys().asSequence().associateWith { totals.getInt(it) },
|
||||
) to entry.savedAtMillis
|
||||
} catch (_: JSONException) { store.remove(scope, KEY); null }
|
||||
}
|
||||
|
||||
fun write(scope: String, library: HomeLibrary, savedAtMillis: Long) {
|
||||
val json = JSONObject().put("resume", encode(library.resume)).put("nextUp", encode(library.nextUp)).put("newest", encode(library.newest))
|
||||
.put("destinations", JSONObject().apply { library.destinations.forEach { (key, titles) -> put(key, encode(titles)) } })
|
||||
.put("totals", JSONObject(library.destinationTotals))
|
||||
store.write(scope, KEY, CacheEntry(json.toString(), null, savedAtMillis, savedAtMillis, group = "home"))
|
||||
}
|
||||
|
||||
private fun encode(titles: List<HomeTitle>): JSONArray = JSONArray().apply {
|
||||
titles.forEach { title -> put(JSONObject().apply {
|
||||
put("id", title.id); put("title", title.title); put("type", title.type); put("year", title.year)
|
||||
put("runtime", title.runtimeSeconds); put("seasons", title.seasons); put("overview", title.overview)
|
||||
put("poster", title.poster); put("backdrop", title.backdrop); put("posterOnly", title.posterOnly)
|
||||
put("seriesTitle", title.seriesTitle); put("season", title.season); put("episode", title.episode)
|
||||
put("resume", title.resumeSeconds); put("played", title.played); put("addedAt", title.addedAt)
|
||||
put("genres", JSONArray(title.genres)); put("tmdb", title.tmdbId)
|
||||
}) }
|
||||
}
|
||||
|
||||
private fun decode(items: JSONArray): List<HomeTitle> = List(items.length()) { index ->
|
||||
val item = items.getJSONObject(index)
|
||||
fun string(key: String): String? = if (item.isNull(key)) null else item.getString(key)
|
||||
fun number(key: String): Int? = if (item.isNull(key)) null else item.getInt(key)
|
||||
val genres = item.getJSONArray("genres")
|
||||
HomeTitle(
|
||||
item.getString("id"), item.getString("title"), item.getString("type"), number("year"), number("runtime"), number("seasons"), item.getString("overview"),
|
||||
string("poster"), string("backdrop"), item.getBoolean("posterOnly"), string("seriesTitle"), number("season"), number("episode"), item.getDouble("resume"), item.getBoolean("played"), item.getString("addedAt"),
|
||||
List(genres.length()) { genres.getString(it) }, held = true, tmdbId = number("tmdb"),
|
||||
)
|
||||
}
|
||||
|
||||
companion object { private const val KEY = "home:jellyfin" }
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
package org.justinvisser.wolfskin.tv.read
|
||||
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.services.ImageUrlService
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
||||
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.jellyfin.sdk.model.api.ItemFields
|
||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||
import org.jellyfin.sdk.model.api.MediaType
|
||||
import org.jellyfin.sdk.model.api.SortOrder
|
||||
import org.justinvisser.wolfskin.design.HomeLibrary
|
||||
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) {
|
||||
suspend fun read(userId: UUID): HomeLibrary {
|
||||
val resume = api.itemsApi.getResumeItems(userId = userId, fields = FIELDS, mediaTypes = listOf(MediaType.VIDEO), limit = 20).content.items.map(::title)
|
||||
val next = api.tvShowsApi.getNextUp(userId = userId, fields = FIELDS, limit = 20, enableUserData = true).content.items.map(::title)
|
||||
val libraries = api.userViewsApi.getUserViews(userId = userId).content.items
|
||||
val destinations = linkedMapOf<String, MutableList<HomeTitle>>()
|
||||
val newest = mutableListOf<HomeTitle>()
|
||||
val totals = linkedMapOf<String, Int>()
|
||||
for (library in libraries) {
|
||||
val key = when {
|
||||
"anime" in library.name.orEmpty().lowercase() -> "anime"
|
||||
library.collectionType == CollectionType.MOVIES -> "movies"
|
||||
library.collectionType == CollectionType.TVSHOWS -> "shows"
|
||||
else -> continue
|
||||
}
|
||||
val response = api.itemsApi.getItems(userId = userId, parentId = library.id, recursive = true, includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), fields = FIELDS, sortBy = listOf(ItemSortBy.IS_PLAYED, ItemSortBy.DATE_CREATED), sortOrder = listOf(SortOrder.ASCENDING, SortOrder.DESCENDING), limit = 20).content
|
||||
destinations.getOrPut(key) { mutableListOf() }.addAll(response.items.map(::title))
|
||||
totals[key] = (totals[key] ?: 0) + response.totalRecordCount
|
||||
newest.addAll(api.itemsApi.getItems(userId = userId, parentId = library.id, recursive = true, includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), fields = FIELDS, sortBy = listOf(ItemSortBy.DATE_CREATED), sortOrder = listOf(SortOrder.DESCENDING), limit = 12).content.items.map(::title))
|
||||
}
|
||||
return HomeLibrary(resume, next, newest.sortedByDescending { it.addedAt }.take(12), destinations, totals)
|
||||
}
|
||||
|
||||
suspend fun playableId(userId: UUID, title: HomeTitle): UUID? {
|
||||
title.id.let { runCatching { UUID.fromString(it) }.getOrNull() }?.let { id ->
|
||||
val item = api.userLibraryApi.getItem(userId = userId, itemId = id).content
|
||||
if (item.type == BaseItemKind.SERIES) {
|
||||
return (api.tvShowsApi.getNextUp(userId = userId, seriesId = id, limit = 1).content.items.firstOrNull()
|
||||
?: api.tvShowsApi.getEpisodes(seriesId = id, userId = userId, limit = 1).content.items.firstOrNull())
|
||||
?.takeIf { it.type == BaseItemKind.EPISODE }?.id
|
||||
}
|
||||
return item.id.takeIf { item.type == BaseItemKind.MOVIE || item.type == BaseItemKind.EPISODE }
|
||||
}
|
||||
val kinds = if (title.isEpisode) listOf(BaseItemKind.EPISODE) else listOf(BaseItemKind.MOVIE)
|
||||
return api.itemsApi.getItems(userId = userId, recursive = true, searchTerm = title.title, includeItemTypes = kinds, fields = FIELDS, limit = 20).content.items.firstOrNull {
|
||||
it.name == title.title && (!title.isEpisode || (it.seriesName == title.seriesTitle && it.parentIndexNumber == title.season && it.indexNumber == title.episode))
|
||||
}?.id
|
||||
}
|
||||
|
||||
private fun title(item: BaseItemDto): HomeTitle {
|
||||
val wrapped = BaseItem(item)
|
||||
return HomeTitle(
|
||||
id = item.id.toString(), title = item.name.orEmpty(), type = when (item.type) { BaseItemKind.EPISODE -> "episode"; BaseItemKind.SERIES -> "tv"; else -> "movie" },
|
||||
year = item.productionYear, runtimeSeconds = item.runTimeTicks?.div(TICKS_PER_SECOND)?.toInt(), seasons = item.childCount,
|
||||
overview = item.overview.orEmpty(), poster = images.getItemImageUrl(wrapped, ImageType.PRIMARY, fillWidth = 256, fillHeight = 384),
|
||||
backdrop = images.getItemImageUrl(wrapped, ImageType.BACKDROP, fillWidth = 1280, fillHeight = 720),
|
||||
seriesTitle = item.seriesName, season = item.parentIndexNumber, episode = item.indexNumber,
|
||||
resumeSeconds = (item.userData?.playbackPositionTicks ?: 0) / TICKS_PER_SECOND.toDouble(), played = item.userData?.played == true,
|
||||
addedAt = item.dateCreated?.toString().orEmpty(), genres = item.genres.orEmpty(), held = true, tmdbId = item.providerIds?.get("Tmdb")?.toIntOrNull(),
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TICKS_PER_SECOND = 10_000_000L
|
||||
private val FIELDS = listOf(ItemFields.PROVIDER_IDS, ItemFields.OVERVIEW, ItemFields.DATE_CREATED, ItemFields.GENRES, ItemFields.CHILD_COUNT)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue