diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleSeasonReelTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleSeasonReelTest.kt new file mode 100644 index 00000000..93253dd1 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleSeasonReelTest.kt @@ -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() + @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() + + @Before fun setup() { + mockkObject(WholphinApplication.Companion) + every { WholphinApplication.instance } returns mockk { + every { resources } returns rule.activity.resources + } + val library = mockk() + val items = mockk() + val shows = mockk() + val api = mockk() + 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()) } returns successQueryResult((1..8).map { number -> + BaseItemDto(id = UUID(0, 10L + number), type = BaseItemKind.SEASON, indexNumber = number) + }) + coEvery { shows.getEpisodes(any()) } returns successQueryResult(episodes) + coEvery { shows.getNextUp(any()) } returns successQueryResult(listOf(episodes.last())) + val playback = mockk() + coEvery { playback.getSelectedTracks(any(), any(), any()) } returns null + val repository = mockk { + every { currentServer } returns null + every { currentUser } returns null + } + val context = mockk { every { filesDir } returns directory.root } + facade = TitleReadFacade(api, mockk(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 { + 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 { + 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() + } +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/TitleReadFacade.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/TitleReadFacade.kt index 1530b8df..974e22c8 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/TitleReadFacade.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/TitleReadFacade.kt @@ -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) } diff --git a/docker/README.md b/docker/README.md index 3c13904b..c57ae04f 100644 --- a/docker/README.md +++ b/docker/README.md @@ -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`