Round 2 release candidate 3: polish lanes A-E, season reel and trailer return #82

Merged
justinzeus merged 27 commits from fm/tv-round2-candidate3 into fm/tv-round2 2026-11-08 13:58:41 +01:00
3 changed files with 181 additions and 1 deletions
Showing only changes of commit d418a89a25 - Show all commits

View file

@ -0,0 +1,169 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import android.content.Context
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.assertIsDisplayed
import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import androidx.compose.ui.unit.dp
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.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.ui.successQueryResult
import com.github.damontecres.wholphin.ui.successResponse
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.unmockkObject
import kotlinx.coroutines.runBlocking
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.userLibraryApi
import org.jellyfin.sdk.api.operations.ItemsApi
import org.jellyfin.sdk.api.operations.TvShowsApi
import org.jellyfin.sdk.api.operations.UserLibraryApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.request.GetEpisodesRequest
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.design.ShellPerson
import org.justinvisser.wolfskin.design.ShellState
import org.justinvisser.wolfskin.design.TitleAction
import org.justinvisser.wolfskin.design.TitlePageData
import org.justinvisser.wolfskin.design.TitleState
import org.justinvisser.wolfskin.design.TitleSurface
import org.justinvisser.wolfskin.design.WolfskinTheme
import org.justinvisser.wolfskin.tv.read.TitleReadFacade
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import java.util.UUID
/** The actual SDK projection and native reel must agree on chronology and the activated episode. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34, 36], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class TitleSeasonReelTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@get:Rule val directory = TemporaryFolder()
private val seriesId = UUID(0, 1)
private val series = BaseItemDto(id = seriesId, name = "Desperate Housewives", type = BaseItemKind.SERIES,
providerIds = mapOf("Tmdb" to "693"), people = emptyList())
private val episodes = (1..24).map { number ->
BaseItemDto(id = UUID(0, 100L + number), name = "Episode $number", type = BaseItemKind.EPISODE,
seriesId = seriesId, parentIndexNumber = 5, indexNumber = number,
providerIds = mapOf("Tmdb" to number.toString()), people = emptyList(),
userData = playbackUserData(UUID(0, 100L + number), 0, played = number < 24))
}
private val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null)
private lateinit var facade: TitleReadFacade
private lateinit var state: TitleState
private val actions = mutableListOf<TitleAction>()
@Before fun setup() {
mockkObject(WholphinApplication.Companion)
every { WholphinApplication.instance } returns mockk {
every { resources } returns rule.activity.resources
}
val library = mockk<UserLibraryApi>()
val items = mockk<ItemsApi>()
val shows = mockk<TvShowsApi>()
val api = mockk<ApiClient>()
every { api.userLibraryApi } returns library
every { api.itemsApi } returns items
every { api.tvShowsApi } returns shows
coEvery { library.getItem(seriesId) } returns successResponse(series)
episodes.forEach { episode -> coEvery { library.getItem(episode.id) } returns successResponse(episode) }
coEvery { items.getItems(any<GetItemsRequest>()) } returns successQueryResult((1..8).map { number ->
BaseItemDto(id = UUID(0, 10L + number), type = BaseItemKind.SEASON, indexNumber = number)
})
coEvery { shows.getEpisodes(any<GetEpisodesRequest>()) } returns successQueryResult(episodes)
coEvery { shows.getNextUp(any<GetNextUpRequest>()) } returns successQueryResult(listOf(episodes.last()))
val playback = mockk<ItemPlaybackRepository>()
coEvery { playback.getSelectedTracks(any(), any(), any()) } returns null
val repository = mockk<ServerRepository> {
every { currentServer } returns null
every { currentUser } returns null
}
val context = mockk<Context> { every { filesDir } returns directory.root }
facade = TitleReadFacade(api, mockk<ImageUrlService>(relaxed = true), playback, repository, mockk(), context)
}
@After fun teardown() { unmockkObject(WholphinApplication.Companion) }
private fun render(page: TitlePageData) {
val viewer = ShellPerson("justin", "Justin", "J", false)
val shell = ShellState(listOf(viewer), viewer.key).apply {
pickerVisible = false; pickerLaunch = false; enterDestination("title/series")
}
state = TitleState(page, viewer, actions::add)
rule.setContent { WolfskinTheme { TitleSurface(state, shell, Modifier.requiredSize(960.dp, 540.dp)) } }
press(Key.DirectionDown)
press(Key.DirectionDown)
repeat(4) { press(Key.DirectionLeft) }
assertEquals("episodes", state.row)
assertEquals(0, state.column)
rule.onNodeWithTag("title-page").assertIsFocused()
}
@Test fun nextUpAtSeasonEndCannotMoveTheFinalEpisodeToTheStartOfTheReel() = runBlocking<Unit> {
val page = facade.read(seriesId, 5, preferences)
render(page)
press(Key.DirectionCenter)
assertEquals("The first tile must open E1, not next-up E24", episodes.first().id.toString(), openedEpisode())
assertEquals("Play remains next-up even though the reel is chronological", episodes.last().id.toString(), page.playTarget?.id)
assertEquals((1..24).toList(), page.episodes.map { it.episode })
val saved = requireNotNull(facade.readSaved(seriesId, preferences))
assertEquals(page.episodes.map { it.id }, saved.episodes.map { it.id })
repeat(7) { press(Key.DirectionRight) }
repeat(2) { press(Key.DirectionLeft) }
press(Key.DirectionUp)
press(Key.DirectionDown)
press(Key.DirectionCenter)
assertEquals("Row return and Left/Right preserve E6", episodes[5].id.toString(), openedEpisode())
rule.onNodeWithTag("title-episode-slot-5").assertIsDisplayed()
}
@Test fun openingAnotherEpisodeAndRefreshingCannotReorderTheRememberedReel() = runBlocking<Unit> {
val opened = facade.read(episodes[11].id, 5, preferences)
render(opened)
repeat(12) { press(Key.DirectionRight) }
assertEquals(episodes[12].id.toString(), state.data.episodes[state.column].id)
val refreshed = facade.read(seriesId, 5, preferences)
rule.runOnIdle { state.update(refreshed) }
press(Key.DirectionCenter)
assertEquals("Refresh must not change E13 into another episode", episodes[12].id.toString(), openedEpisode())
assertEquals(episodes[11].id.toString(), opened.playTarget?.id)
assertEquals(episodes[11].id.toString(), opened.watchTarget.id)
assertEquals(opened.episodes.map { it.id }, refreshed.episodes.map { it.id })
rule.onNodeWithTag("title-episode-slot-12").assertIsDisplayed()
}
private fun openedEpisode() = (actions.last() as TitleAction.OpenTitle).item.id
private fun press(key: Key) {
rule.onRoot().performKeyInput { pressKey(key) }
rule.mainClock.advanceTimeBy(400)
rule.waitForIdle()
}
}

View file

@ -192,6 +192,9 @@ class TitleReadFacade @Inject constructor(
streams.filter { it.type == MediaStreamType.AUDIO }.map { TitleTrack(it.index, it.displayTitle ?: it.language ?: it.codec.orEmpty(), it.isDefault) },
streams.filter { it.type == MediaStreamType.SUBTITLE }.map { TitleTrack(it.index, it.displayTitle ?: it.language ?: it.codec.orEmpty(), it.isDefault) })
}
// Rich playback/watch rows are prefixed in storage, not in the season's display order.
val seasonEpisodes = rows.filterTo(ArrayList()) { it.type == BaseItemKind.EPISODE && it.parentIndexNumber == selectedSeason }
seasonEpisodes.sortBy { it.indexNumber }
val pageItem = mapItem(item).copy(seasons = seasonNumbers.size)
return TitlePageData(pageItem, target?.takeIf { it.type == BaseItemKind.EPISODE || (it.userData?.playbackPositionTicks ?: 0) > 0 }?.let { mapItem(it, pageItem.backdrop) },
rows.first { it.id.toString() == watchId }.let { mapItem(it, pageItem.backdrop) }, versions, chosen?.source?.id, chosen?.audioStream?.stream?.index,
@ -200,7 +203,7 @@ class TitleReadFacade @Inject constructor(
cast = item.people.orEmpty().filter { it.type.toString().equals("Actor", true) }.map { person ->
TitlePerson(person.id.toString(), person.name.orEmpty(), person.role.orEmpty(), person.primaryImageTag?.let { images.getItemImageUrl(person.id, ImageType.PRIMARY, maxWidth = 200, maxHeight = 200, tag = it) })
},
episodes = rows.filter { it.type == BaseItemKind.EPISODE && it.parentIndexNumber == selectedSeason }.map { mapItem(it, pageItem.backdrop) },
episodes = seasonEpisodes.map { mapItem(it, pageItem.backdrop) },
connection = TitleConnection.SignedOut, seasonNumbers = seasonNumbers, selectedSeason = selectedSeason)
}

View file

@ -1007,6 +1007,14 @@ Episode shelf skeletons use the same width, height and pitch as ready cards.
Person filmography and related-title shelves remain posters; Home continuation
tiles are a separate component and retain their series-name context.
Season episode reels use episode-number order independently of the Play/Resume
or explicitly opened episode. The shared live/saved projection keeps rich
playback metadata without promoting that target into the first tile. A refresh
therefore cannot replace the episode under a remembered reel index merely
because the playback target changed. `TitleSeasonReelTest` replays native remote
input on SDK 34 and 36 with 24 season-five episodes and next-up at E24, including
row return, exact activation, cached reads and an explicit-episode refresh.
`TitleReadFacade` confines SDK reads, server/user-scoped last-good disk copies,
person credits and playback mutations to one boundary. `ConductarrTitlePages`