mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-23 19:26:50 +00:00
Compare commits
8 Commits
2937ee836b
...
b91a8a81e8
| Author | SHA1 | Date | |
|---|---|---|---|
| b91a8a81e8 | |||
| 9e8772467c | |||
| 60291dc78e | |||
| 0870e8aee4 | |||
| f0bb37d8ec | |||
| 34ae15d645 | |||
| 9083a08f95 | |||
| 32d50f750f |
@@ -6,16 +6,17 @@ import androidx.compose.ui.test.assertCountEquals
|
|||||||
import androidx.compose.ui.test.assertIsDisplayed
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
import androidx.compose.ui.test.assertIsFocused
|
import androidx.compose.ui.test.assertIsFocused
|
||||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
import androidx.compose.ui.test.performKeyInput
|
|
||||||
import androidx.compose.ui.test.pressKey
|
|
||||||
import androidx.compose.ui.test.onAllNodesWithText
|
import androidx.compose.ui.test.onAllNodesWithText
|
||||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
import androidx.compose.ui.test.onRoot
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.performKeyInput
|
||||||
|
import androidx.compose.ui.test.pressKey
|
||||||
import androidx.compose.ui.input.key.Key
|
import androidx.compose.ui.input.key.Key
|
||||||
import hu.bbara.purefin.core.model.CastMember
|
import hu.bbara.purefin.core.model.CastMember
|
||||||
import hu.bbara.purefin.core.model.Episode
|
import hu.bbara.purefin.core.model.Episode
|
||||||
import hu.bbara.purefin.ui.theme.AppTheme
|
import hu.bbara.purefin.ui.theme.AppTheme
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
@@ -27,13 +28,12 @@ class EpisodeScreenContentTest {
|
|||||||
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun episodeScreenContent_showsSeriesContext_andMovesFromBackToPlayButton() {
|
fun episodeScreenContent_showsSeriesContext_andFocusesPlayButton() {
|
||||||
composeRule.setContent {
|
composeRule.setContent {
|
||||||
AppTheme {
|
AppTheme {
|
||||||
EpisodeScreenContent(
|
EpisodeScreenContent(
|
||||||
episode = sampleEpisode(progress = 63.0),
|
episode = sampleEpisode(progress = 63.0),
|
||||||
seriesTitle = "Severance",
|
seriesTitle = "Severance",
|
||||||
onBack = {},
|
|
||||||
onPlay = {}
|
onPlay = {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -48,12 +48,6 @@ class EpisodeScreenContentTest {
|
|||||||
composeRule.onAllNodesWithText("Playback").assertCountEquals(1)
|
composeRule.onAllNodesWithText("Playback").assertCountEquals(1)
|
||||||
composeRule.onNodeWithTag(EpisodePlayButtonTag).assertIsDisplayed()
|
composeRule.onNodeWithTag(EpisodePlayButtonTag).assertIsDisplayed()
|
||||||
composeRule.onNodeWithText("Series").assertIsDisplayed()
|
composeRule.onNodeWithText("Series").assertIsDisplayed()
|
||||||
composeRule.onNodeWithContentDescription("Back")
|
|
||||||
.assertIsDisplayed()
|
|
||||||
.assertIsFocused()
|
|
||||||
.performKeyInput {
|
|
||||||
pressKey(Key.DirectionDown)
|
|
||||||
}
|
|
||||||
composeRule.onNodeWithTag(EpisodePlayButtonTag).assertIsFocused()
|
composeRule.onNodeWithTag(EpisodePlayButtonTag).assertIsFocused()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +58,6 @@ class EpisodeScreenContentTest {
|
|||||||
EpisodeScreenContent(
|
EpisodeScreenContent(
|
||||||
episode = sampleEpisode(progress = null),
|
episode = sampleEpisode(progress = null),
|
||||||
seriesTitle = "Severance",
|
seriesTitle = "Severance",
|
||||||
onBack = {},
|
|
||||||
onPlay = {}
|
onPlay = {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -78,6 +71,32 @@ class EpisodeScreenContentTest {
|
|||||||
composeRule.onAllNodesWithText("Series").assertCountEquals(0)
|
composeRule.onAllNodesWithText("Series").assertCountEquals(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun episodeScreenContent_startsPlaybackOnFirstCenterPress() {
|
||||||
|
var playCount = 0
|
||||||
|
|
||||||
|
composeRule.setContent {
|
||||||
|
AppTheme {
|
||||||
|
EpisodeScreenContent(
|
||||||
|
episode = sampleEpisode(progress = 63.0),
|
||||||
|
seriesTitle = "Severance",
|
||||||
|
onPlay = { playCount++ }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
|
||||||
|
composeRule.onNodeWithTag(EpisodePlayButtonTag).assertIsFocused()
|
||||||
|
composeRule.onRoot().performKeyInput {
|
||||||
|
pressKey(Key.DirectionCenter)
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
|
||||||
|
assertEquals(1, playCount)
|
||||||
|
}
|
||||||
|
|
||||||
private fun sampleEpisode(progress: Double?): Episode {
|
private fun sampleEpisode(progress: Double?): Episode {
|
||||||
val seriesId = UUID.fromString("11111111-1111-1111-1111-111111111111")
|
val seriesId = UUID.fromString("11111111-1111-1111-1111-111111111111")
|
||||||
val seasonId = UUID.fromString("22222222-2222-2222-2222-222222222222")
|
val seasonId = UUID.fromString("22222222-2222-2222-2222-222222222222")
|
||||||
|
|||||||
@@ -6,16 +6,17 @@ import androidx.compose.ui.test.assertCountEquals
|
|||||||
import androidx.compose.ui.test.assertIsDisplayed
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
import androidx.compose.ui.test.assertIsFocused
|
import androidx.compose.ui.test.assertIsFocused
|
||||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
import androidx.compose.ui.test.performKeyInput
|
|
||||||
import androidx.compose.ui.test.pressKey
|
|
||||||
import androidx.compose.ui.test.onAllNodesWithText
|
import androidx.compose.ui.test.onAllNodesWithText
|
||||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
import androidx.compose.ui.test.onRoot
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.performKeyInput
|
||||||
|
import androidx.compose.ui.test.pressKey
|
||||||
import androidx.compose.ui.input.key.Key
|
import androidx.compose.ui.input.key.Key
|
||||||
import hu.bbara.purefin.core.model.CastMember
|
import hu.bbara.purefin.core.model.CastMember
|
||||||
import hu.bbara.purefin.core.model.Movie
|
import hu.bbara.purefin.core.model.Movie
|
||||||
import hu.bbara.purefin.ui.theme.AppTheme
|
import hu.bbara.purefin.ui.theme.AppTheme
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
@@ -27,12 +28,11 @@ class MovieScreenContentTest {
|
|||||||
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun movieScreenContent_focusesBack_thenMovesToPlayButton() {
|
fun movieScreenContent_focusesPlayButton() {
|
||||||
composeRule.setContent {
|
composeRule.setContent {
|
||||||
AppTheme {
|
AppTheme {
|
||||||
MovieScreenContent(
|
MovieScreenContent(
|
||||||
movie = sampleMovie(progress = 42.0),
|
movie = sampleMovie(progress = 42.0),
|
||||||
onBack = {},
|
|
||||||
onPlay = {}
|
onPlay = {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -43,14 +43,34 @@ class MovieScreenContentTest {
|
|||||||
composeRule.onNodeWithText("Blade Runner 2049").assertIsDisplayed()
|
composeRule.onNodeWithText("Blade Runner 2049").assertIsDisplayed()
|
||||||
composeRule.onNodeWithText("Overview").assertIsDisplayed()
|
composeRule.onNodeWithText("Overview").assertIsDisplayed()
|
||||||
composeRule.onAllNodesWithText("Playback").assertCountEquals(1)
|
composeRule.onAllNodesWithText("Playback").assertCountEquals(1)
|
||||||
composeRule.onNodeWithTag(MoviePlayButtonTag).assertIsDisplayed()
|
composeRule.onNodeWithTag(MoviePlayButtonTag)
|
||||||
composeRule.onNodeWithContentDescription("Back")
|
|
||||||
.assertIsDisplayed()
|
.assertIsDisplayed()
|
||||||
.assertIsFocused()
|
.assertIsFocused()
|
||||||
.performKeyInput {
|
|
||||||
pressKey(Key.DirectionDown)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun movieScreenContent_startsPlaybackOnFirstCenterPress() {
|
||||||
|
var playCount = 0
|
||||||
|
|
||||||
|
composeRule.setContent {
|
||||||
|
AppTheme {
|
||||||
|
MovieScreenContent(
|
||||||
|
movie = sampleMovie(progress = 42.0),
|
||||||
|
onPlay = { playCount++ }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
|
||||||
composeRule.onNodeWithTag(MoviePlayButtonTag).assertIsFocused()
|
composeRule.onNodeWithTag(MoviePlayButtonTag).assertIsFocused()
|
||||||
|
composeRule.onRoot().performKeyInput {
|
||||||
|
pressKey(Key.DirectionCenter)
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
|
||||||
|
assertEquals(1, playCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun sampleMovie(progress: Double?): Movie {
|
private fun sampleMovie(progress: Double?): Movie {
|
||||||
|
|||||||
@@ -5,17 +5,18 @@ import androidx.compose.ui.test.ExperimentalTestApi
|
|||||||
import androidx.compose.ui.test.assertIsDisplayed
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
import androidx.compose.ui.test.assertIsFocused
|
import androidx.compose.ui.test.assertIsFocused
|
||||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
import androidx.compose.ui.test.performKeyInput
|
import androidx.compose.ui.test.onRoot
|
||||||
import androidx.compose.ui.test.pressKey
|
|
||||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.performKeyInput
|
||||||
|
import androidx.compose.ui.test.pressKey
|
||||||
import androidx.compose.ui.input.key.Key
|
import androidx.compose.ui.input.key.Key
|
||||||
import hu.bbara.purefin.core.model.CastMember
|
import hu.bbara.purefin.core.model.CastMember
|
||||||
import hu.bbara.purefin.core.model.Episode
|
import hu.bbara.purefin.core.model.Episode
|
||||||
import hu.bbara.purefin.core.model.Season
|
import hu.bbara.purefin.core.model.Season
|
||||||
import hu.bbara.purefin.core.model.Series
|
import hu.bbara.purefin.core.model.Series
|
||||||
import hu.bbara.purefin.ui.theme.AppTheme
|
import hu.bbara.purefin.ui.theme.AppTheme
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
@@ -27,12 +28,11 @@ class SeriesScreenContentTest {
|
|||||||
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun seriesScreenContent_movesFromBackToPrimaryAction_whenNextUpExists() {
|
fun seriesScreenContent_focusesPrimaryAction_whenNextUpExists() {
|
||||||
composeRule.setContent {
|
composeRule.setContent {
|
||||||
AppTheme {
|
AppTheme {
|
||||||
SeriesScreenContent(
|
SeriesScreenContent(
|
||||||
series = sampleSeriesWithEpisodes(),
|
series = sampleSeriesWithEpisodes(),
|
||||||
onBack = {},
|
|
||||||
onPlayEpisode = {}
|
onPlayEpisode = {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -44,25 +44,18 @@ class SeriesScreenContentTest {
|
|||||||
composeRule.onNodeWithText("Overview").assertIsDisplayed()
|
composeRule.onNodeWithText("Overview").assertIsDisplayed()
|
||||||
composeRule.onNodeWithText("Continue Watching").assertIsDisplayed()
|
composeRule.onNodeWithText("Continue Watching").assertIsDisplayed()
|
||||||
composeRule.onNodeWithTag(SeriesPlayButtonTag).assertIsDisplayed()
|
composeRule.onNodeWithTag(SeriesPlayButtonTag).assertIsDisplayed()
|
||||||
|
.assertIsFocused()
|
||||||
composeRule.onNodeWithText("Season 1").assertIsDisplayed()
|
composeRule.onNodeWithText("Season 1").assertIsDisplayed()
|
||||||
composeRule.onNodeWithText("Good News About Hell").assertIsDisplayed()
|
composeRule.onNodeWithText("Good News About Hell").assertIsDisplayed()
|
||||||
composeRule.onNodeWithText("Episode 1 • 57m").assertIsDisplayed()
|
composeRule.onNodeWithText("Episode 1 • 57m").assertIsDisplayed()
|
||||||
composeRule.onNodeWithContentDescription("Back")
|
|
||||||
.assertIsDisplayed()
|
|
||||||
.assertIsFocused()
|
|
||||||
.performKeyInput {
|
|
||||||
pressKey(Key.DirectionDown)
|
|
||||||
}
|
|
||||||
composeRule.onNodeWithTag(SeriesPlayButtonTag).assertIsFocused()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun seriesScreenContent_movesFromBackToFirstSeason_whenNoPlayableEpisodeExists() {
|
fun seriesScreenContent_focusesFirstSeason_whenNoPlayableEpisodeExists() {
|
||||||
composeRule.setContent {
|
composeRule.setContent {
|
||||||
AppTheme {
|
AppTheme {
|
||||||
SeriesScreenContent(
|
SeriesScreenContent(
|
||||||
series = sampleSeriesWithoutEpisodes(),
|
series = sampleSeriesWithoutEpisodes(),
|
||||||
onBack = {},
|
|
||||||
onPlayEpisode = {}
|
onPlayEpisode = {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -72,14 +65,34 @@ class SeriesScreenContentTest {
|
|||||||
|
|
||||||
composeRule.onNodeWithText("Overview").assertIsDisplayed()
|
composeRule.onNodeWithText("Overview").assertIsDisplayed()
|
||||||
composeRule.onNodeWithText("Choose a season below to start watching.").assertIsDisplayed()
|
composeRule.onNodeWithText("Choose a season below to start watching.").assertIsDisplayed()
|
||||||
composeRule.onNodeWithTag(SeriesFirstSeasonTabTag).assertIsDisplayed()
|
composeRule.onNodeWithTag(SeriesFirstSeasonTabTag)
|
||||||
composeRule.onNodeWithContentDescription("Back")
|
|
||||||
.assertIsDisplayed()
|
.assertIsDisplayed()
|
||||||
.assertIsFocused()
|
.assertIsFocused()
|
||||||
.performKeyInput {
|
|
||||||
pressKey(Key.DirectionDown)
|
|
||||||
}
|
}
|
||||||
composeRule.onNodeWithTag(SeriesFirstSeasonTabTag).assertIsFocused()
|
|
||||||
|
@Test
|
||||||
|
fun seriesScreenContent_startsPlaybackOnFirstCenterPress() {
|
||||||
|
var playCount = 0
|
||||||
|
|
||||||
|
composeRule.setContent {
|
||||||
|
AppTheme {
|
||||||
|
SeriesScreenContent(
|
||||||
|
series = sampleSeriesWithEpisodes(),
|
||||||
|
onPlayEpisode = { playCount++ }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
|
||||||
|
composeRule.onNodeWithTag(SeriesPlayButtonTag).assertIsFocused()
|
||||||
|
composeRule.onRoot().performKeyInput {
|
||||||
|
pressKey(Key.DirectionCenter)
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
|
||||||
|
assertEquals(1, playCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun sampleSeriesWithEpisodes(): Series {
|
private fun sampleSeriesWithEpisodes(): Series {
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package hu.bbara.purefin.common.ui.components
|
||||||
|
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class TvMediaDetailBringIntoViewSpecTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun returnsZero_whenFocusedItemIsAlreadyFullyVisible() {
|
||||||
|
assertEquals(
|
||||||
|
0f,
|
||||||
|
TvMediaDetailBringIntoViewSpec.calculateScrollDistance(
|
||||||
|
offset = 320f,
|
||||||
|
size = 52f,
|
||||||
|
containerSize = 1080f
|
||||||
|
),
|
||||||
|
0.0001f
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun scrollsOnlyEnoughToRevealFocusedItemBelowViewport() {
|
||||||
|
assertEquals(
|
||||||
|
36f,
|
||||||
|
TvMediaDetailBringIntoViewSpec.calculateScrollDistance(
|
||||||
|
offset = 1064f,
|
||||||
|
size = 52f,
|
||||||
|
containerSize = 1080f
|
||||||
|
),
|
||||||
|
0.0001f
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun scrollsBackJustToTopEdge_whenFocusedItemMovesAboveViewport() {
|
||||||
|
assertEquals(
|
||||||
|
-24f,
|
||||||
|
TvMediaDetailBringIntoViewSpec.calculateScrollDistance(
|
||||||
|
offset = -24f,
|
||||||
|
size = 52f,
|
||||||
|
containerSize = 1080f
|
||||||
|
),
|
||||||
|
0.0001f
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,20 +9,24 @@ import androidx.compose.ui.test.assertIsDisplayed
|
|||||||
import androidx.compose.ui.test.assertIsFocused
|
import androidx.compose.ui.test.assertIsFocused
|
||||||
import androidx.compose.ui.test.assertTextEquals
|
import androidx.compose.ui.test.assertTextEquals
|
||||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onRoot
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
import androidx.compose.ui.test.performKeyInput
|
import androidx.compose.ui.test.performKeyInput
|
||||||
import androidx.compose.ui.test.pressKey
|
import androidx.compose.ui.test.pressKey
|
||||||
import androidx.compose.ui.input.key.Key
|
import androidx.compose.ui.input.key.Key
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import hu.bbara.purefin.core.model.Episode
|
||||||
import hu.bbara.purefin.core.model.Movie
|
import hu.bbara.purefin.core.model.Movie
|
||||||
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem
|
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem
|
||||||
import hu.bbara.purefin.feature.shared.home.LibraryItem
|
import hu.bbara.purefin.feature.shared.home.LibraryItem
|
||||||
|
import hu.bbara.purefin.feature.shared.home.NextUpItem
|
||||||
import hu.bbara.purefin.feature.shared.home.PosterItem
|
import hu.bbara.purefin.feature.shared.home.PosterItem
|
||||||
import hu.bbara.purefin.tv.home.TvHomeScreen
|
import hu.bbara.purefin.tv.home.TvHomeScreen
|
||||||
import hu.bbara.purefin.ui.theme.AppTheme
|
import hu.bbara.purefin.ui.theme.AppTheme
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.api.CollectionType
|
import org.jellyfin.sdk.model.api.CollectionType
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
@@ -140,6 +144,157 @@ class TvHomeContentTest {
|
|||||||
.assertIsFocused()
|
.assertIsFocused()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun tvHomeScreen_movesFocusedSectionToTopOfContent() {
|
||||||
|
composeRule.setContent {
|
||||||
|
AppTheme {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.size(width = 960.dp, height = 540.dp)
|
||||||
|
) {
|
||||||
|
TvHomeScreen(
|
||||||
|
libraries = sampleLibraries(),
|
||||||
|
libraryContent = sampleLibraryContent(),
|
||||||
|
continueWatching = sampleContinueWatching(),
|
||||||
|
nextUp = sampleNextUp(),
|
||||||
|
serverUrl = "",
|
||||||
|
onMovieSelected = {},
|
||||||
|
onSeriesSelected = {},
|
||||||
|
onEpisodeSelected = { _, _, _ -> }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
|
||||||
|
composeRule.onRoot().performKeyInput {
|
||||||
|
pressKey(Key.DirectionDown)
|
||||||
|
}
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
|
||||||
|
composeRule.onNodeWithTag(TvHomeHeroTitleTag).assertTextEquals("The Train Job")
|
||||||
|
assertSectionRowAlignedToViewportTop(TvHomeNextUpRowTag)
|
||||||
|
|
||||||
|
composeRule.onRoot().performKeyInput {
|
||||||
|
pressKey(Key.DirectionDown)
|
||||||
|
}
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
|
||||||
|
composeRule.onNodeWithTag(TvHomeHeroTitleTag).assertTextEquals("Arrival")
|
||||||
|
assertSectionRowAlignedToViewportTop(tvHomeLibraryRowTag(sampleLibraryId()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun tvHomeScreen_skipsMissingSectionAndAlignsLibraryToTop() {
|
||||||
|
composeRule.setContent {
|
||||||
|
AppTheme {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.size(width = 960.dp, height = 540.dp)
|
||||||
|
) {
|
||||||
|
TvHomeScreen(
|
||||||
|
libraries = sampleLibraries(),
|
||||||
|
libraryContent = sampleLibraryContent(),
|
||||||
|
continueWatching = sampleContinueWatching(),
|
||||||
|
nextUp = emptyList(),
|
||||||
|
serverUrl = "",
|
||||||
|
onMovieSelected = {},
|
||||||
|
onSeriesSelected = {},
|
||||||
|
onEpisodeSelected = { _, _, _ -> }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
|
||||||
|
composeRule.onRoot().performKeyInput {
|
||||||
|
pressKey(Key.DirectionDown)
|
||||||
|
}
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
|
||||||
|
composeRule.onNodeWithTag(TvHomeHeroTitleTag).assertTextEquals("Arrival")
|
||||||
|
assertSectionRowAlignedToViewportTop(tvHomeLibraryRowTag(sampleLibraryId()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun tvHomeScreen_movesPreviousSectionToTopWhenNavigatingUp() {
|
||||||
|
composeRule.setContent {
|
||||||
|
AppTheme {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.size(width = 960.dp, height = 540.dp)
|
||||||
|
) {
|
||||||
|
TvHomeScreen(
|
||||||
|
libraries = sampleLibraries(),
|
||||||
|
libraryContent = sampleLibraryContent(),
|
||||||
|
continueWatching = sampleContinueWatching(),
|
||||||
|
nextUp = sampleNextUp(),
|
||||||
|
serverUrl = "",
|
||||||
|
onMovieSelected = {},
|
||||||
|
onSeriesSelected = {},
|
||||||
|
onEpisodeSelected = { _, _, _ -> }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
|
||||||
|
composeRule.onRoot().performKeyInput {
|
||||||
|
pressKey(Key.DirectionDown)
|
||||||
|
pressKey(Key.DirectionDown)
|
||||||
|
}
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
|
||||||
|
composeRule.onRoot().performKeyInput {
|
||||||
|
pressKey(Key.DirectionUp)
|
||||||
|
}
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
|
||||||
|
composeRule.onNodeWithTag(TvHomeHeroTitleTag).assertTextEquals("The Train Job")
|
||||||
|
assertSectionRowAlignedToViewportTop(TvHomeNextUpRowTag)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun tvHomeScreen_horizontalMoveDoesNotChangeAlignedSectionPosition() {
|
||||||
|
composeRule.setContent {
|
||||||
|
AppTheme {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.size(width = 960.dp, height = 540.dp)
|
||||||
|
) {
|
||||||
|
TvHomeScreen(
|
||||||
|
libraries = sampleLibraries(),
|
||||||
|
libraryContent = sampleLibraryContent(),
|
||||||
|
continueWatching = sampleContinueWatching(),
|
||||||
|
nextUp = sampleNextUpRow(),
|
||||||
|
serverUrl = "",
|
||||||
|
onMovieSelected = {},
|
||||||
|
onSeriesSelected = {},
|
||||||
|
onEpisodeSelected = { _, _, _ -> }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
|
||||||
|
composeRule.onRoot().performKeyInput {
|
||||||
|
pressKey(Key.DirectionDown)
|
||||||
|
}
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
|
||||||
|
assertSectionRowAlignedToViewportTop(TvHomeNextUpRowTag)
|
||||||
|
val alignedTopBefore = rowTop(TvHomeNextUpRowTag)
|
||||||
|
|
||||||
|
composeRule.onRoot().performKeyInput {
|
||||||
|
pressKey(Key.DirectionRight)
|
||||||
|
}
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
|
||||||
|
composeRule.onNodeWithTag(TvHomeHeroTitleTag).assertTextEquals("Safe")
|
||||||
|
val alignedTopAfter = rowTop(TvHomeNextUpRowTag)
|
||||||
|
assertEquals(alignedTopBefore, alignedTopAfter, 2f)
|
||||||
|
}
|
||||||
|
|
||||||
private fun sampleContinueWatching(): List<ContinueWatchingItem> {
|
private fun sampleContinueWatching(): List<ContinueWatchingItem> {
|
||||||
return listOf(
|
return listOf(
|
||||||
ContinueWatchingItem(
|
ContinueWatchingItem(
|
||||||
@@ -174,10 +329,39 @@ class TvHomeContentTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun sampleNextUp(): List<NextUpItem> {
|
||||||
|
return listOf(
|
||||||
|
NextUpItem(
|
||||||
|
episode = sampleEpisode(
|
||||||
|
id = "66666666-6666-6666-6666-666666666666",
|
||||||
|
title = "The Train Job"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sampleNextUpRow(): List<NextUpItem> {
|
||||||
|
return listOf(
|
||||||
|
NextUpItem(
|
||||||
|
episode = sampleEpisode(
|
||||||
|
id = "66666666-6666-6666-6666-666666666666",
|
||||||
|
title = "The Train Job"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
NextUpItem(
|
||||||
|
episode = sampleEpisode(
|
||||||
|
id = "77777777-7777-7777-7777-777777777777",
|
||||||
|
title = "Safe",
|
||||||
|
releaseDate = "2024-02-09"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun sampleLibraries(): List<LibraryItem> {
|
private fun sampleLibraries(): List<LibraryItem> {
|
||||||
return listOf(
|
return listOf(
|
||||||
LibraryItem(
|
LibraryItem(
|
||||||
id = UUID.fromString("33333333-3333-3333-3333-333333333333"),
|
id = sampleLibraryId(),
|
||||||
name = "Movies",
|
name = "Movies",
|
||||||
type = CollectionType.MOVIES,
|
type = CollectionType.MOVIES,
|
||||||
posterUrl = "",
|
posterUrl = "",
|
||||||
@@ -187,7 +371,7 @@ class TvHomeContentTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun sampleLibraryContent(): Map<UUID, List<PosterItem>> {
|
private fun sampleLibraryContent(): Map<UUID, List<PosterItem>> {
|
||||||
val libraryId = UUID.fromString("33333333-3333-3333-3333-333333333333")
|
val libraryId = sampleLibraryId()
|
||||||
|
|
||||||
return mapOf(
|
return mapOf(
|
||||||
libraryId to listOf(
|
libraryId to listOf(
|
||||||
@@ -204,6 +388,32 @@ class TvHomeContentTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun sampleEpisode(
|
||||||
|
id: String,
|
||||||
|
title: String,
|
||||||
|
releaseDate: String = "2002-09-20",
|
||||||
|
progress: Double? = 18.0,
|
||||||
|
seriesId: UUID = UUID.fromString("88888888-8888-8888-8888-888888888888"),
|
||||||
|
seasonId: UUID = UUID.fromString("99999999-9999-9999-9999-999999999999")
|
||||||
|
): Episode {
|
||||||
|
return Episode(
|
||||||
|
id = UUID.fromString(id),
|
||||||
|
seriesId = seriesId,
|
||||||
|
seasonId = seasonId,
|
||||||
|
index = 1,
|
||||||
|
title = title,
|
||||||
|
synopsis = "A crew member takes the shuttle for a spin and makes a mess.",
|
||||||
|
releaseDate = releaseDate,
|
||||||
|
rating = "16+",
|
||||||
|
runtime = "44m",
|
||||||
|
progress = progress,
|
||||||
|
watched = false,
|
||||||
|
format = "HD",
|
||||||
|
imageUrlPrefix = "https://images.unsplash.com/photo-1511497584788-876760111969",
|
||||||
|
cast = emptyList()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun sampleMovie(
|
private fun sampleMovie(
|
||||||
id: String,
|
id: String,
|
||||||
title: String,
|
title: String,
|
||||||
@@ -227,4 +437,29 @@ class TvHomeContentTest {
|
|||||||
cast = emptyList()
|
cast = emptyList()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun sampleLibraryId(): UUID {
|
||||||
|
return UUID.fromString("33333333-3333-3333-3333-333333333333")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertSectionRowAlignedToViewportTop(sectionRowTag: String) {
|
||||||
|
val expectedRowTop = viewportTop() + with(composeRule.density) {
|
||||||
|
TvHomeFocusedItemTopOffset.toPx()
|
||||||
|
}
|
||||||
|
assertEquals(expectedRowTop, rowTop(sectionRowTag), 2f)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun viewportTop(): Float {
|
||||||
|
return composeRule.onNodeWithTag(TvHomeContentViewportTag, useUnmergedTree = true)
|
||||||
|
.fetchSemanticsNode()
|
||||||
|
.boundsInRoot
|
||||||
|
.top
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rowTop(sectionRowTag: String): Float {
|
||||||
|
return composeRule.onNodeWithTag(sectionRowTag, useUnmergedTree = true)
|
||||||
|
.fetchSemanticsNode()
|
||||||
|
.boundsInRoot
|
||||||
|
.top
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.withFrameNanos
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -17,15 +18,14 @@ import hu.bbara.purefin.common.ui.PurefinWaitingScreen
|
|||||||
import hu.bbara.purefin.common.ui.components.MediaDetailOverviewSection
|
import hu.bbara.purefin.common.ui.components.MediaDetailOverviewSection
|
||||||
import hu.bbara.purefin.common.ui.components.MediaDetailPlaybackSection
|
import hu.bbara.purefin.common.ui.components.MediaDetailPlaybackSection
|
||||||
import hu.bbara.purefin.common.ui.components.MediaDetailSectionTitle
|
import hu.bbara.purefin.common.ui.components.MediaDetailSectionTitle
|
||||||
|
import hu.bbara.purefin.common.ui.components.TvMediaDetailBodyBox
|
||||||
import hu.bbara.purefin.common.ui.components.TvMediaDetailScaffold
|
import hu.bbara.purefin.common.ui.components.TvMediaDetailScaffold
|
||||||
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
|
|
||||||
import hu.bbara.purefin.core.data.navigation.EpisodeDto
|
|
||||||
import hu.bbara.purefin.core.data.navigation.LocalNavigationBackStack
|
|
||||||
import hu.bbara.purefin.core.data.navigation.LocalNavigationManager
|
import hu.bbara.purefin.core.data.navigation.LocalNavigationManager
|
||||||
import hu.bbara.purefin.core.data.navigation.Route
|
import hu.bbara.purefin.core.data.navigation.Route
|
||||||
|
import hu.bbara.purefin.common.ui.components.tvMediaDetailBackgroundImageUrl
|
||||||
|
import hu.bbara.purefin.core.data.navigation.EpisodeDto
|
||||||
import hu.bbara.purefin.core.model.Episode
|
import hu.bbara.purefin.core.model.Episode
|
||||||
import hu.bbara.purefin.feature.shared.content.episode.EpisodeScreenViewModel
|
import hu.bbara.purefin.feature.shared.content.episode.EpisodeScreenViewModel
|
||||||
import org.jellyfin.sdk.model.api.ImageType
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun EpisodeScreen(
|
fun EpisodeScreen(
|
||||||
@@ -34,8 +34,6 @@ fun EpisodeScreen(
|
|||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val navigationManager = LocalNavigationManager.current
|
val navigationManager = LocalNavigationManager.current
|
||||||
val backStack = LocalNavigationBackStack.current
|
|
||||||
val previousRoute = remember(backStack) { backStack.getOrNull(backStack.lastIndex - 1) }
|
|
||||||
|
|
||||||
LaunchedEffect(episode) {
|
LaunchedEffect(episode) {
|
||||||
viewModel.selectEpisode(
|
viewModel.selectEpisode(
|
||||||
@@ -57,7 +55,6 @@ fun EpisodeScreen(
|
|||||||
EpisodeScreenContent(
|
EpisodeScreenContent(
|
||||||
episode = selectedEpisode,
|
episode = selectedEpisode,
|
||||||
seriesTitle = seriesTitle.value,
|
seriesTitle = seriesTitle.value,
|
||||||
onBack = viewModel::onBack,
|
|
||||||
onPlay = remember(selectedEpisode.id, navigationManager) {
|
onPlay = remember(selectedEpisode.id, navigationManager) {
|
||||||
{
|
{
|
||||||
navigationManager.navigate(
|
navigationManager.navigate(
|
||||||
@@ -73,23 +70,25 @@ fun EpisodeScreen(
|
|||||||
internal fun EpisodeScreenContent(
|
internal fun EpisodeScreenContent(
|
||||||
episode: Episode,
|
episode: Episode,
|
||||||
seriesTitle: String?,
|
seriesTitle: String?,
|
||||||
onBack: () -> Unit,
|
|
||||||
onPlay: () -> Unit,
|
onPlay: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val playFocusRequester = remember { FocusRequester() }
|
val playFocusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
LaunchedEffect(episode.id) {
|
LaunchedEffect(episode.id) {
|
||||||
|
withFrameNanos { }
|
||||||
playFocusRequester.requestFocus()
|
playFocusRequester.requestFocus()
|
||||||
}
|
}
|
||||||
|
|
||||||
TvMediaDetailScaffold(
|
TvMediaDetailScaffold(
|
||||||
artworkImageUrl = JellyfinImageHelper.finishImageUrl(episode.imageUrlPrefix, ImageType.PRIMARY),
|
|
||||||
artworkWidth = 280.dp,
|
|
||||||
artworkAspectRatio = 16f / 9f,
|
|
||||||
resetScrollKey = episode.id,
|
resetScrollKey = episode.id,
|
||||||
modifier = modifier,
|
modifier = modifier
|
||||||
heroContent = {
|
) {
|
||||||
|
item {
|
||||||
|
TvMediaDetailBodyBox(
|
||||||
|
backgroundImageUrl = tvMediaDetailBackgroundImageUrl(episode.imageUrlPrefix),
|
||||||
|
modifier = it
|
||||||
|
) {
|
||||||
EpisodeHeroSection(
|
EpisodeHeroSection(
|
||||||
episode = episode,
|
episode = episode,
|
||||||
seriesTitle = seriesTitle,
|
seriesTitle = seriesTitle,
|
||||||
@@ -99,7 +98,7 @@ internal fun EpisodeScreenContent(
|
|||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
}
|
}
|
||||||
) {
|
}
|
||||||
item {
|
item {
|
||||||
Column(modifier = it.fillMaxWidth()) {
|
Column(modifier = it.fillMaxWidth()) {
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.withFrameNanos
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -17,12 +18,12 @@ import hu.bbara.purefin.common.ui.PurefinWaitingScreen
|
|||||||
import hu.bbara.purefin.common.ui.components.MediaDetailOverviewSection
|
import hu.bbara.purefin.common.ui.components.MediaDetailOverviewSection
|
||||||
import hu.bbara.purefin.common.ui.components.MediaDetailPlaybackSection
|
import hu.bbara.purefin.common.ui.components.MediaDetailPlaybackSection
|
||||||
import hu.bbara.purefin.common.ui.components.MediaDetailSectionTitle
|
import hu.bbara.purefin.common.ui.components.MediaDetailSectionTitle
|
||||||
|
import hu.bbara.purefin.common.ui.components.TvMediaDetailBodyBox
|
||||||
import hu.bbara.purefin.common.ui.components.TvMediaDetailScaffold
|
import hu.bbara.purefin.common.ui.components.TvMediaDetailScaffold
|
||||||
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
|
import hu.bbara.purefin.common.ui.components.tvMediaDetailBackgroundImageUrl
|
||||||
import hu.bbara.purefin.core.data.navigation.MovieDto
|
import hu.bbara.purefin.core.data.navigation.MovieDto
|
||||||
import hu.bbara.purefin.core.model.Movie
|
import hu.bbara.purefin.core.model.Movie
|
||||||
import hu.bbara.purefin.feature.shared.content.movie.MovieScreenViewModel
|
import hu.bbara.purefin.feature.shared.content.movie.MovieScreenViewModel
|
||||||
import org.jellyfin.sdk.model.api.ImageType
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MovieScreen(
|
fun MovieScreen(
|
||||||
@@ -37,7 +38,6 @@ fun MovieScreen(
|
|||||||
if (movieItem.value != null) {
|
if (movieItem.value != null) {
|
||||||
MovieScreenContent(
|
MovieScreenContent(
|
||||||
movie = movieItem.value!!,
|
movie = movieItem.value!!,
|
||||||
onBack = viewModel::onBack,
|
|
||||||
onPlay = viewModel::onPlay,
|
onPlay = viewModel::onPlay,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
)
|
)
|
||||||
@@ -49,23 +49,25 @@ fun MovieScreen(
|
|||||||
@Composable
|
@Composable
|
||||||
internal fun MovieScreenContent(
|
internal fun MovieScreenContent(
|
||||||
movie: Movie,
|
movie: Movie,
|
||||||
onBack: () -> Unit,
|
|
||||||
onPlay: () -> Unit,
|
onPlay: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val playFocusRequester = remember { FocusRequester() }
|
val playFocusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
LaunchedEffect(movie.id) {
|
LaunchedEffect(movie.id) {
|
||||||
|
withFrameNanos { }
|
||||||
playFocusRequester.requestFocus()
|
playFocusRequester.requestFocus()
|
||||||
}
|
}
|
||||||
|
|
||||||
TvMediaDetailScaffold(
|
TvMediaDetailScaffold(
|
||||||
artworkImageUrl = JellyfinImageHelper.finishImageUrl(movie.imageUrlPrefix, ImageType.PRIMARY),
|
|
||||||
artworkWidth = 200.dp,
|
|
||||||
artworkAspectRatio = 2f / 3f,
|
|
||||||
resetScrollKey = movie.id,
|
resetScrollKey = movie.id,
|
||||||
modifier = modifier,
|
modifier = modifier
|
||||||
heroContent = {
|
) {
|
||||||
|
item {
|
||||||
|
TvMediaDetailBodyBox(
|
||||||
|
backgroundImageUrl = tvMediaDetailBackgroundImageUrl(movie.imageUrlPrefix),
|
||||||
|
modifier = it
|
||||||
|
) {
|
||||||
MovieHeroSection(
|
MovieHeroSection(
|
||||||
movie = movie,
|
movie = movie,
|
||||||
onPlay = onPlay,
|
onPlay = onPlay,
|
||||||
@@ -74,7 +76,7 @@ internal fun MovieScreenContent(
|
|||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
}
|
}
|
||||||
) {
|
}
|
||||||
item {
|
item {
|
||||||
Column(modifier = it.fillMaxWidth()) {
|
Column(modifier = it.fillMaxWidth()) {
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|||||||
@@ -7,8 +7,11 @@ import androidx.compose.foundation.layout.height
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.withFrameNanos
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -16,14 +19,14 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
|||||||
import hu.bbara.purefin.common.ui.PurefinWaitingScreen
|
import hu.bbara.purefin.common.ui.PurefinWaitingScreen
|
||||||
import hu.bbara.purefin.common.ui.components.MediaDetailOverviewSection
|
import hu.bbara.purefin.common.ui.components.MediaDetailOverviewSection
|
||||||
import hu.bbara.purefin.common.ui.components.MediaDetailSectionTitle
|
import hu.bbara.purefin.common.ui.components.MediaDetailSectionTitle
|
||||||
|
import hu.bbara.purefin.common.ui.components.TvMediaDetailBodyBox
|
||||||
import hu.bbara.purefin.common.ui.components.TvMediaDetailScaffold
|
import hu.bbara.purefin.common.ui.components.TvMediaDetailScaffold
|
||||||
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
|
import hu.bbara.purefin.common.ui.components.tvMediaDetailBackgroundImageUrl
|
||||||
import hu.bbara.purefin.core.data.navigation.SeriesDto
|
import hu.bbara.purefin.core.data.navigation.SeriesDto
|
||||||
import hu.bbara.purefin.core.model.Season
|
import hu.bbara.purefin.core.model.Season
|
||||||
import hu.bbara.purefin.core.model.Series
|
import hu.bbara.purefin.core.model.Series
|
||||||
import hu.bbara.purefin.feature.shared.content.series.SeriesViewModel
|
import hu.bbara.purefin.feature.shared.content.series.SeriesViewModel
|
||||||
import org.jellyfin.sdk.model.UUID
|
import org.jellyfin.sdk.model.UUID
|
||||||
import org.jellyfin.sdk.model.api.ImageType
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SeriesScreen(
|
fun SeriesScreen(
|
||||||
@@ -41,7 +44,6 @@ fun SeriesScreen(
|
|||||||
if (seriesData != null && seriesData.seasons.isNotEmpty()) {
|
if (seriesData != null && seriesData.seasons.isNotEmpty()) {
|
||||||
SeriesScreenContent(
|
SeriesScreenContent(
|
||||||
series = seriesData,
|
series = seriesData,
|
||||||
onBack = viewModel::onBack,
|
|
||||||
onPlayEpisode = viewModel::onPlayEpisode,
|
onPlayEpisode = viewModel::onPlayEpisode,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
)
|
)
|
||||||
@@ -53,27 +55,16 @@ fun SeriesScreen(
|
|||||||
@Composable
|
@Composable
|
||||||
internal fun SeriesScreenContent(
|
internal fun SeriesScreenContent(
|
||||||
series: Series,
|
series: Series,
|
||||||
onBack: () -> Unit,
|
|
||||||
onPlayEpisode: (UUID) -> Unit,
|
onPlayEpisode: (UUID) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
fun getDefaultSeason(): Season {
|
var selectedSeason by remember(series.id) { mutableStateOf(series.defaultSeason()) }
|
||||||
for (season in series.seasons) {
|
val nextUpEpisode = remember(series.id) { series.nextUpEpisode() }
|
||||||
val firstUnwatchedEpisode = season.episodes.firstOrNull { it.watched.not() }
|
|
||||||
if (firstUnwatchedEpisode != null) return season
|
|
||||||
}
|
|
||||||
return series.seasons.first()
|
|
||||||
}
|
|
||||||
val selectedSeason = remember(series.id) { mutableStateOf(getDefaultSeason()) }
|
|
||||||
val nextUpEpisode = remember(series.id) {
|
|
||||||
series.seasons.firstNotNullOfOrNull { season ->
|
|
||||||
season.episodes.firstOrNull { !it.watched }
|
|
||||||
} ?: series.seasons.firstOrNull()?.episodes?.firstOrNull()
|
|
||||||
}
|
|
||||||
val playFocusRequester = remember { FocusRequester() }
|
val playFocusRequester = remember { FocusRequester() }
|
||||||
val firstContentFocusRequester = remember { FocusRequester() }
|
val firstContentFocusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
LaunchedEffect(series.id, nextUpEpisode?.id) {
|
LaunchedEffect(series.id, nextUpEpisode?.id) {
|
||||||
|
withFrameNanos { }
|
||||||
if (nextUpEpisode != null) {
|
if (nextUpEpisode != null) {
|
||||||
playFocusRequester.requestFocus()
|
playFocusRequester.requestFocus()
|
||||||
} else {
|
} else {
|
||||||
@@ -82,12 +73,14 @@ internal fun SeriesScreenContent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
TvMediaDetailScaffold(
|
TvMediaDetailScaffold(
|
||||||
artworkImageUrl = JellyfinImageHelper.finishImageUrl(series.imageUrlPrefix, ImageType.PRIMARY),
|
|
||||||
artworkWidth = 200.dp,
|
|
||||||
artworkAspectRatio = 2f / 3f,
|
|
||||||
resetScrollKey = series.id,
|
resetScrollKey = series.id,
|
||||||
modifier = modifier,
|
modifier = modifier
|
||||||
heroContent = {
|
) {
|
||||||
|
item {
|
||||||
|
TvMediaDetailBodyBox(
|
||||||
|
backgroundImageUrl = tvMediaDetailBackgroundImageUrl(series.imageUrlPrefix),
|
||||||
|
modifier = it
|
||||||
|
) {
|
||||||
SeriesHeroSection(
|
SeriesHeroSection(
|
||||||
series = series,
|
series = series,
|
||||||
nextUpEpisode = nextUpEpisode,
|
nextUpEpisode = nextUpEpisode,
|
||||||
@@ -98,7 +91,7 @@ internal fun SeriesScreenContent(
|
|||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
}
|
}
|
||||||
) {
|
}
|
||||||
item {
|
item {
|
||||||
Column(modifier = it.fillMaxWidth()) {
|
Column(modifier = it.fillMaxWidth()) {
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
@@ -112,17 +105,17 @@ internal fun SeriesScreenContent(
|
|||||||
item {
|
item {
|
||||||
SeasonTabs(
|
SeasonTabs(
|
||||||
seasons = series.seasons,
|
seasons = series.seasons,
|
||||||
selectedSeason = selectedSeason.value,
|
selectedSeason = selectedSeason,
|
||||||
firstItemFocusRequester = firstContentFocusRequester,
|
firstItemFocusRequester = firstContentFocusRequester,
|
||||||
firstItemTestTag = SeriesFirstSeasonTabTag,
|
firstItemTestTag = SeriesFirstSeasonTabTag,
|
||||||
onSelect = { selectedSeason.value = it },
|
onSelect = { selectedSeason = it },
|
||||||
modifier = it
|
modifier = it
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
Spacer(modifier = Modifier.height(20.dp))
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
EpisodeCarousel(
|
EpisodeCarousel(
|
||||||
episodes = selectedSeason.value.episodes,
|
episodes = selectedSeason.episodes,
|
||||||
modifier = it
|
modifier = it
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -139,3 +132,16 @@ internal fun SeriesScreenContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun Series.defaultSeason(): Season {
|
||||||
|
for (season in seasons) {
|
||||||
|
if (season.episodes.any { !it.watched }) {
|
||||||
|
return season
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return seasons.first()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Series.nextUpEpisode() = seasons.firstNotNullOfOrNull { season ->
|
||||||
|
season.episodes.firstOrNull { !it.watched }
|
||||||
|
} ?: seasons.firstOrNull()?.episodes?.firstOrNull()
|
||||||
|
|||||||
@@ -1,57 +1,66 @@
|
|||||||
|
@file:OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
|
||||||
|
|
||||||
package hu.bbara.purefin.common.ui.components
|
package hu.bbara.purefin.common.ui.components
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.gestures.BringIntoViewSpec
|
||||||
|
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.layout.widthIn
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyListScope
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import hu.bbara.purefin.common.ui.MediaSynopsis
|
import hu.bbara.purefin.common.ui.MediaSynopsis
|
||||||
|
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
|
||||||
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
|
|
||||||
internal val MediaDetailHorizontalPadding = 48.dp
|
internal val MediaDetailHorizontalPadding = 48.dp
|
||||||
private val MediaDetailHeaderTopPadding = 104.dp
|
private val MediaDetailHeaderTopPadding = 104.dp
|
||||||
private val MediaDetailHeaderBottomPadding = 36.dp
|
private val MediaDetailHeaderBottomPadding = 36.dp
|
||||||
private val MediaDetailCornerArtworkTopPadding = 40.dp
|
private const val MediaDetailBodyImageWidthFraction = 0.66f
|
||||||
private val MediaDetailCornerArtworkShape = RoundedCornerShape(24.dp)
|
|
||||||
private val MediaDetailMinimumContentWidth = 280.dp
|
internal val TvMediaDetailBringIntoViewSpec: BringIntoViewSpec =
|
||||||
private val MediaDetailContentArtworkGap = 32.dp
|
object : BringIntoViewSpec {
|
||||||
|
override fun calculateScrollDistance(
|
||||||
|
offset: Float,
|
||||||
|
size: Float,
|
||||||
|
containerSize: Float,
|
||||||
|
): Float {
|
||||||
|
val trailingEdge = offset + size
|
||||||
|
|
||||||
|
return when {
|
||||||
|
offset < 0f -> offset
|
||||||
|
trailingEdge > containerSize -> trailingEdge - containerSize
|
||||||
|
else -> 0f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun TvMediaDetailScaffold(
|
internal fun TvMediaDetailScaffold(
|
||||||
artworkImageUrl: String,
|
|
||||||
artworkWidth: Dp,
|
|
||||||
artworkAspectRatio: Float,
|
|
||||||
resetScrollKey: Any,
|
resetScrollKey: Any,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
headerHeightFraction: Float = 0.48f,
|
|
||||||
heroContent: @Composable ColumnScope.() -> Unit,
|
|
||||||
bodyContent: LazyListScope.(Modifier) -> Unit = { _ -> }
|
bodyContent: LazyListScope.(Modifier) -> Unit = { _ -> }
|
||||||
) {
|
) {
|
||||||
val scheme = MaterialTheme.colorScheme
|
val scheme = MaterialTheme.colorScheme
|
||||||
@@ -62,132 +71,89 @@ internal fun TvMediaDetailScaffold(
|
|||||||
listState.scrollToItem(0)
|
listState.scrollToItem(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(
|
CompositionLocalProvider(LocalBringIntoViewSpec provides TvMediaDetailBringIntoViewSpec) {
|
||||||
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(scheme.background)
|
.background(scheme.background)
|
||||||
) {
|
) {
|
||||||
LazyColumn(
|
|
||||||
state = listState,
|
|
||||||
modifier = Modifier.fillMaxSize()
|
|
||||||
) {
|
|
||||||
item {
|
|
||||||
TvMediaDetailHeader(
|
|
||||||
artworkImageUrl = artworkImageUrl,
|
|
||||||
artworkWidth = artworkWidth,
|
|
||||||
artworkAspectRatio = artworkAspectRatio,
|
|
||||||
headerHeightFraction = headerHeightFraction,
|
|
||||||
heroContent = heroContent
|
|
||||||
)
|
|
||||||
}
|
|
||||||
bodyContent(contentPadding)
|
bodyContent(contentPadding)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TvMediaDetailHeader(
|
internal fun TvMediaDetailBodyBox(
|
||||||
artworkImageUrl: String,
|
backgroundImageUrl: String,
|
||||||
artworkWidth: Dp,
|
modifier: Modifier = Modifier,
|
||||||
artworkAspectRatio: Float,
|
heightFraction: Float = 0.48f,
|
||||||
headerHeightFraction: Float,
|
content: @Composable ColumnScope.() -> Unit
|
||||||
heroContent: @Composable ColumnScope.() -> Unit
|
|
||||||
) {
|
) {
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
val screenHeight = LocalConfiguration.current.screenHeightDp.dp
|
val screenHeight = LocalConfiguration.current.screenHeightDp.dp
|
||||||
val headerHeight = screenHeight * headerHeightFraction
|
val bodyHeight = screenHeight * heightFraction
|
||||||
|
|
||||||
BoxWithConstraints(
|
Box(
|
||||||
modifier = Modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.heightIn(min = headerHeight)
|
.heightIn(min = bodyHeight)
|
||||||
) {
|
) {
|
||||||
val contentMaxWidth = (
|
Box(modifier = Modifier.matchParentSize()) {
|
||||||
maxWidth -
|
PurefinAsyncImage(
|
||||||
(MediaDetailHorizontalPadding * 2) -
|
model = backgroundImageUrl,
|
||||||
artworkWidth -
|
contentDescription = null,
|
||||||
MediaDetailContentArtworkGap
|
|
||||||
).coerceAtLeast(MediaDetailMinimumContentWidth)
|
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
TvMediaDetailCornerArtwork(
|
|
||||||
artworkImageUrl = artworkImageUrl,
|
|
||||||
artworkWidth = artworkWidth,
|
|
||||||
artworkAspectRatio = artworkAspectRatio,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.TopEnd)
|
.fillMaxHeight()
|
||||||
.padding(
|
.fillMaxWidth(MediaDetailBodyImageWidthFraction)
|
||||||
top = MediaDetailCornerArtworkTopPadding,
|
.align(Alignment.TopEnd),
|
||||||
end = MediaDetailHorizontalPadding
|
contentScale = ContentScale.Crop
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.matchParentSize()
|
||||||
|
.background(
|
||||||
|
Brush.horizontalGradient(
|
||||||
|
colorStops = arrayOf(
|
||||||
|
0.0f to scheme.background,
|
||||||
|
0.28f to scheme.background.copy(alpha = 0.88f),
|
||||||
|
0.62f to scheme.background.copy(alpha = 0.42f),
|
||||||
|
1.0f to scheme.background.copy(alpha = 0.12f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.matchParentSize()
|
||||||
|
.background(
|
||||||
|
Brush.verticalGradient(
|
||||||
|
colorStops = arrayOf(
|
||||||
|
0.0f to scheme.background.copy(alpha = 0.08f),
|
||||||
|
0.55f to scheme.background.copy(alpha = 0.16f),
|
||||||
|
1.0f to scheme.background
|
||||||
|
)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.TopStart)
|
.fillMaxWidth()
|
||||||
.padding(
|
.padding(
|
||||||
start = MediaDetailHorizontalPadding,
|
|
||||||
top = MediaDetailHeaderTopPadding,
|
top = MediaDetailHeaderTopPadding,
|
||||||
end = MediaDetailHorizontalPadding,
|
|
||||||
bottom = MediaDetailHeaderBottomPadding
|
bottom = MediaDetailHeaderBottomPadding
|
||||||
)
|
)
|
||||||
.widthIn(max = contentMaxWidth)
|
|
||||||
) {
|
) {
|
||||||
heroContent()
|
content()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
internal fun tvMediaDetailBackgroundImageUrl(imageUrlPrefix: String?): String {
|
||||||
private fun TvMediaDetailCornerArtwork(
|
val primaryImageUrl = JellyfinImageHelper.finishImageUrl(imageUrlPrefix, ImageType.PRIMARY)
|
||||||
artworkImageUrl: String,
|
val backdropImageUrl = JellyfinImageHelper.finishImageUrl(imageUrlPrefix, ImageType.BACKDROP)
|
||||||
artworkWidth: Dp,
|
return backdropImageUrl.ifBlank { primaryImageUrl }
|
||||||
artworkAspectRatio: Float,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
val scheme = MaterialTheme.colorScheme
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = modifier
|
|
||||||
.width(artworkWidth)
|
|
||||||
.aspectRatio(artworkAspectRatio)
|
|
||||||
.clip(MediaDetailCornerArtworkShape)
|
|
||||||
.background(scheme.surfaceVariant.copy(alpha = 0.28f))
|
|
||||||
) {
|
|
||||||
PurefinAsyncImage(
|
|
||||||
model = artworkImageUrl,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.graphicsLayer { alpha = 0.52f },
|
|
||||||
contentScale = ContentScale.Crop
|
|
||||||
)
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(
|
|
||||||
Brush.horizontalGradient(
|
|
||||||
colorStops = arrayOf(
|
|
||||||
0.0f to scheme.background.copy(alpha = 0.08f),
|
|
||||||
0.55f to scheme.background.copy(alpha = 0.2f),
|
|
||||||
1.0f to scheme.background.copy(alpha = 0.56f)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(
|
|
||||||
Brush.verticalGradient(
|
|
||||||
colorStops = arrayOf(
|
|
||||||
0.0f to scheme.background.copy(alpha = 0.06f),
|
|
||||||
0.65f to scheme.background.copy(alpha = 0.22f),
|
|
||||||
1.0f to scheme.background.copy(alpha = 0.74f)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -68,8 +68,8 @@ fun MediaResumeButton(
|
|||||||
.border(3.dp, focusBorderColor, focusShape)
|
.border(3.dp, focusBorderColor, focusShape)
|
||||||
.clip(focusShape)
|
.clip(focusShape)
|
||||||
.onFocusChanged { isFocused = it.isFocused || it.hasFocus }
|
.onFocusChanged { isFocused = it.isFocused || it.hasFocus }
|
||||||
.focusable()
|
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
|
.focusable()
|
||||||
) {
|
) {
|
||||||
// Bottom layer: inverted colors (visible for the remaining %)
|
// Bottom layer: inverted colors (visible for the remaining %)
|
||||||
Box(
|
Box(
|
||||||
|
|||||||
@@ -16,12 +16,8 @@ import kotlin.math.roundToInt
|
|||||||
internal data class TvFocusedHeroModel(
|
internal data class TvFocusedHeroModel(
|
||||||
val id: UUID,
|
val id: UUID,
|
||||||
val backdropImageUrl: String,
|
val backdropImageUrl: String,
|
||||||
val eyebrowText: String,
|
|
||||||
val title: String,
|
val title: String,
|
||||||
val metadata: List<String>,
|
val metadata: List<String>,
|
||||||
val watchedText: String?,
|
|
||||||
val progressFraction: Float?,
|
|
||||||
val progressLabel: String?,
|
|
||||||
) {
|
) {
|
||||||
val metadataText: String?
|
val metadataText: String?
|
||||||
get() = metadata.takeIf { it.isNotEmpty() }?.joinToString(" • ")
|
get() = metadata.takeIf { it.isNotEmpty() }?.joinToString(" • ")
|
||||||
@@ -30,16 +26,16 @@ internal data class TvFocusedHeroModel(
|
|||||||
internal fun FocusableItem.toTvFocusedHeroModel(): TvFocusedHeroModel {
|
internal fun FocusableItem.toTvFocusedHeroModel(): TvFocusedHeroModel {
|
||||||
return when (this) {
|
return when (this) {
|
||||||
is ContinueWatchingItem -> when (type) {
|
is ContinueWatchingItem -> when (type) {
|
||||||
BaseItemKind.MOVIE -> movie!!.toTvFocusedHeroModel(sourceLabel = "Continue watching")
|
BaseItemKind.MOVIE -> movie!!.toTvFocusedHeroModel()
|
||||||
BaseItemKind.EPISODE -> episode!!.toTvFocusedHeroModel(sourceLabel = "Continue watching")
|
BaseItemKind.EPISODE -> episode!!.toTvFocusedHeroModel()
|
||||||
else -> unsupportedType(type)
|
else -> unsupportedType(type)
|
||||||
}
|
}
|
||||||
|
|
||||||
is NextUpItem -> episode.toTvFocusedHeroModel(sourceLabel = "Next up")
|
is NextUpItem -> episode.toTvFocusedHeroModel()
|
||||||
is PosterItem -> when (type) {
|
is PosterItem -> when (type) {
|
||||||
BaseItemKind.MOVIE -> movie!!.toTvFocusedHeroModel(sourceLabel = "Movie")
|
BaseItemKind.MOVIE -> movie!!.toTvFocusedHeroModel()
|
||||||
BaseItemKind.EPISODE -> episode!!.toTvFocusedHeroModel(sourceLabel = "Episode")
|
BaseItemKind.EPISODE -> episode!!.toTvFocusedHeroModel()
|
||||||
BaseItemKind.SERIES -> series!!.toTvFocusedHeroModel(sourceLabel = "Series")
|
BaseItemKind.SERIES -> series!!.toTvFocusedHeroModel()
|
||||||
else -> unsupportedType(type)
|
else -> unsupportedType(type)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -50,32 +46,25 @@ internal fun backdropImageUrl(imageUrlPrefix: String?, fallbackImageUrl: String)
|
|||||||
return backdropImageUrl.ifBlank { fallbackImageUrl }
|
return backdropImageUrl.ifBlank { fallbackImageUrl }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Movie.toTvFocusedHeroModel(sourceLabel: String): TvFocusedHeroModel {
|
private fun Movie.toTvFocusedHeroModel(): TvFocusedHeroModel {
|
||||||
val progressFraction = progressFraction(progress)
|
|
||||||
return TvFocusedHeroModel(
|
return TvFocusedHeroModel(
|
||||||
id = id,
|
id = id,
|
||||||
backdropImageUrl = backdropImageUrl(
|
backdropImageUrl = backdropImageUrl(
|
||||||
imageUrlPrefix = imageUrlPrefix,
|
imageUrlPrefix = imageUrlPrefix,
|
||||||
fallbackImageUrl = JellyfinImageHelper.finishImageUrl(imageUrlPrefix, ImageType.PRIMARY)
|
fallbackImageUrl = JellyfinImageHelper.finishImageUrl(imageUrlPrefix, ImageType.PRIMARY)
|
||||||
),
|
),
|
||||||
eyebrowText = sourceLabel,
|
|
||||||
title = title,
|
title = title,
|
||||||
metadata = listOf(year, rating, runtime, format).compactMetadata(),
|
metadata = listOf(year, rating, runtime, format).compactMetadata(),
|
||||||
watchedText = "Watched".takeIf { watched },
|
|
||||||
progressFraction = progressFraction.takeIf { !watched && it != null },
|
|
||||||
progressLabel = progressLabel(progressFraction).takeIf { !watched && progressFraction != null },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Episode.toTvFocusedHeroModel(sourceLabel: String): TvFocusedHeroModel {
|
private fun Episode.toTvFocusedHeroModel(): TvFocusedHeroModel {
|
||||||
val progressFraction = progressFraction(progress)
|
|
||||||
return TvFocusedHeroModel(
|
return TvFocusedHeroModel(
|
||||||
id = id,
|
id = id,
|
||||||
backdropImageUrl = backdropImageUrl(
|
backdropImageUrl = backdropImageUrl(
|
||||||
imageUrlPrefix = imageUrlPrefix,
|
imageUrlPrefix = imageUrlPrefix,
|
||||||
fallbackImageUrl = JellyfinImageHelper.finishImageUrl(imageUrlPrefix, ImageType.PRIMARY)
|
fallbackImageUrl = JellyfinImageHelper.finishImageUrl(imageUrlPrefix, ImageType.PRIMARY)
|
||||||
),
|
),
|
||||||
eyebrowText = sourceLabel,
|
|
||||||
title = title,
|
title = title,
|
||||||
metadata = listOf(
|
metadata = listOf(
|
||||||
"Episode $index",
|
"Episode $index",
|
||||||
@@ -84,13 +73,10 @@ private fun Episode.toTvFocusedHeroModel(sourceLabel: String): TvFocusedHeroMode
|
|||||||
rating,
|
rating,
|
||||||
format
|
format
|
||||||
).compactMetadata(),
|
).compactMetadata(),
|
||||||
watchedText = "Watched".takeIf { watched },
|
|
||||||
progressFraction = progressFraction.takeIf { !watched && it != null },
|
|
||||||
progressLabel = progressLabel(progressFraction).takeIf { !watched && progressFraction != null },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Series.toTvFocusedHeroModel(sourceLabel: String): TvFocusedHeroModel {
|
private fun Series.toTvFocusedHeroModel(): TvFocusedHeroModel {
|
||||||
val unwatchedText = if (unwatchedEpisodeCount > 0) {
|
val unwatchedText = if (unwatchedEpisodeCount > 0) {
|
||||||
"$unwatchedEpisodeCount unwatched"
|
"$unwatchedEpisodeCount unwatched"
|
||||||
} else {
|
} else {
|
||||||
@@ -103,12 +89,8 @@ private fun Series.toTvFocusedHeroModel(sourceLabel: String): TvFocusedHeroModel
|
|||||||
imageUrlPrefix = imageUrlPrefix,
|
imageUrlPrefix = imageUrlPrefix,
|
||||||
fallbackImageUrl = JellyfinImageHelper.finishImageUrl(imageUrlPrefix, ImageType.PRIMARY)
|
fallbackImageUrl = JellyfinImageHelper.finishImageUrl(imageUrlPrefix, ImageType.PRIMARY)
|
||||||
),
|
),
|
||||||
eyebrowText = sourceLabel,
|
|
||||||
title = name,
|
title = name,
|
||||||
metadata = listOf(year, seasonLabel(seasonCount), unwatchedText).compactMetadata(),
|
metadata = listOf(year, seasonLabel(seasonCount), unwatchedText).compactMetadata(),
|
||||||
watchedText = null,
|
|
||||||
progressFraction = null,
|
|
||||||
progressLabel = null,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,39 +3,29 @@ package hu.bbara.purefin.tv.home.ui
|
|||||||
import androidx.compose.animation.Crossfade
|
import androidx.compose.animation.Crossfade
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import hu.bbara.purefin.common.ui.components.MediaProgressBar
|
|
||||||
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
|
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
|
||||||
|
|
||||||
internal const val TvHomeHeroTitleTag = "tv-home-hero-title"
|
internal const val TvHomeHeroTitleTag = "tv-home-hero-title"
|
||||||
internal const val TvHomeHeroStatusTag = "tv-home-hero-status"
|
|
||||||
internal const val TvHomeHeroProgressLabelTag = "tv-home-hero-progress-label"
|
internal const val TvHomeHeroProgressLabelTag = "tv-home-hero-progress-label"
|
||||||
|
|
||||||
private const val TvHomeHeroAnimationMillis = 180
|
private const val TvHomeHeroAnimationMillis = 180
|
||||||
@@ -107,13 +97,6 @@ internal fun TvFocusedItemHero(
|
|||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
modifier = Modifier.widthIn(max = 720.dp)
|
modifier = Modifier.widthIn(max = 720.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
|
||||||
text = hero.eyebrowText.uppercase(),
|
|
||||||
color = scheme.primary,
|
|
||||||
fontSize = 10.sp,
|
|
||||||
letterSpacing = 1.2.sp,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
Text(
|
Text(
|
||||||
text = hero.title,
|
text = hero.title,
|
||||||
color = scheme.onBackground,
|
color = scheme.onBackground,
|
||||||
@@ -134,54 +117,6 @@ internal fun TvFocusedItemHero(
|
|||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (hero.watchedText != null || hero.progressFraction != null) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
|
||||||
modifier = Modifier
|
|
||||||
.clip(RoundedCornerShape(22.dp))
|
|
||||||
.background(scheme.surfaceContainerHigh.copy(alpha = 0.92f))
|
|
||||||
.border(1.dp, scheme.outlineVariant.copy(alpha = 0.6f), RoundedCornerShape(22.dp))
|
|
||||||
.padding(horizontal = 12.dp, vertical = 10.dp)
|
|
||||||
) {
|
|
||||||
hero.watchedText?.let { watchedText ->
|
|
||||||
TvHomeMetaChip(
|
|
||||||
text = watchedText,
|
|
||||||
highlighted = true,
|
|
||||||
modifier = Modifier.testTag(TvHomeHeroStatusTag)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (hero.progressFraction != null && hero.progressLabel != null) {
|
|
||||||
Column(
|
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
modifier = Modifier.width(188.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Progress",
|
|
||||||
color = scheme.onSurfaceVariant.copy(alpha = 0.85f),
|
|
||||||
fontSize = 10.sp,
|
|
||||||
fontWeight = FontWeight.Medium
|
|
||||||
)
|
|
||||||
MediaProgressBar(
|
|
||||||
progress = hero.progressFraction,
|
|
||||||
foregroundColor = scheme.primary,
|
|
||||||
backgroundColor = scheme.surfaceVariant,
|
|
||||||
contentPadding = PaddingValues(0.dp),
|
|
||||||
barHeight = 6.dp,
|
|
||||||
modifier = Modifier
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = hero.progressLabel,
|
|
||||||
color = scheme.onBackground,
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
textAlign = TextAlign.End,
|
|
||||||
modifier = Modifier.testTag(TvHomeHeroProgressLabelTag)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
@file:OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
|
||||||
|
|
||||||
|
package hu.bbara.purefin.tv.home.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.gestures.BringIntoViewSpec
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
internal val TvHomeFocusedItemTopOffset = 56.dp
|
||||||
|
internal val TvHomeBringIntoViewTrailingSpace = 120.dp
|
||||||
|
|
||||||
|
private const val TvHomeRowPivotParentFraction = 0.3f
|
||||||
|
private const val TvHomeRowPivotChildFraction = 0f
|
||||||
|
|
||||||
|
internal fun tvHomeColumnBringIntoViewSpec(topOffsetPx: Float): BringIntoViewSpec {
|
||||||
|
return object : BringIntoViewSpec {
|
||||||
|
override fun calculateScrollDistance(
|
||||||
|
offset: Float,
|
||||||
|
size: Float,
|
||||||
|
containerSize: Float,
|
||||||
|
): Float {
|
||||||
|
val leadingEdge = offset
|
||||||
|
val trailingEdge = offset + size
|
||||||
|
val childSize = abs(trailingEdge - leadingEdge)
|
||||||
|
val childSmallerThanParent = childSize <= containerSize
|
||||||
|
val spaceAvailableToShowItem = containerSize - topOffsetPx
|
||||||
|
val targetForLeadingEdge =
|
||||||
|
if (childSmallerThanParent && spaceAvailableToShowItem < childSize) {
|
||||||
|
containerSize - childSize
|
||||||
|
} else {
|
||||||
|
topOffsetPx
|
||||||
|
}
|
||||||
|
|
||||||
|
return leadingEdge - targetForLeadingEdge
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal val TvHomeRowBringIntoViewSpec: BringIntoViewSpec =
|
||||||
|
object : BringIntoViewSpec {
|
||||||
|
override fun calculateScrollDistance(
|
||||||
|
offset: Float,
|
||||||
|
size: Float,
|
||||||
|
containerSize: Float,
|
||||||
|
): Float {
|
||||||
|
val leadingEdge = offset
|
||||||
|
val trailingEdge = offset + size
|
||||||
|
val childSize = abs(trailingEdge - leadingEdge)
|
||||||
|
val childSmallerThanParent = childSize <= containerSize
|
||||||
|
val initialTargetForLeadingEdge =
|
||||||
|
TvHomeRowPivotParentFraction * containerSize -
|
||||||
|
(TvHomeRowPivotChildFraction * childSize)
|
||||||
|
val spaceAvailableToShowItem = containerSize - initialTargetForLeadingEdge
|
||||||
|
val targetForLeadingEdge =
|
||||||
|
if (childSmallerThanParent && spaceAvailableToShowItem < childSize) {
|
||||||
|
containerSize - childSize
|
||||||
|
} else {
|
||||||
|
initialTargetForLeadingEdge
|
||||||
|
}
|
||||||
|
|
||||||
|
return leadingEdge - targetForLeadingEdge
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,18 @@
|
|||||||
|
@file:OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
|
||||||
|
|
||||||
package hu.bbara.purefin.tv.home.ui
|
package hu.bbara.purefin.tv.home.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -17,10 +21,8 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.runtime.withFrameNanos
|
import androidx.compose.runtime.withFrameNanos
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component1
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component2
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.focus.focusProperties
|
|
||||||
import androidx.compose.ui.focus.focusRequester
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem
|
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem
|
||||||
import hu.bbara.purefin.feature.shared.home.FocusableItem
|
import hu.bbara.purefin.feature.shared.home.FocusableItem
|
||||||
@@ -30,6 +32,7 @@ import hu.bbara.purefin.feature.shared.home.PosterItem
|
|||||||
import org.jellyfin.sdk.model.UUID
|
import org.jellyfin.sdk.model.UUID
|
||||||
|
|
||||||
internal const val TvHomeInitialFocusTag = "tv-home-initial-focus-item"
|
internal const val TvHomeInitialFocusTag = "tv-home-initial-focus-item"
|
||||||
|
internal const val TvHomeContentViewportTag = "tv-home-content-viewport"
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TvHomeContent(
|
fun TvHomeContent(
|
||||||
@@ -52,14 +55,18 @@ fun TvHomeContent(
|
|||||||
nextUp = nextUp
|
nextUp = nextUp
|
||||||
)
|
)
|
||||||
val visibleLibraries = itemRegistry.visibleLibraries
|
val visibleLibraries = itemRegistry.visibleLibraries
|
||||||
val (nextUpRef, continueWatchingRef) = remember { FocusRequester.createRefs() }
|
val hasContinueWatching = continueWatching.isNotEmpty()
|
||||||
val libraryRefs = remember(visibleLibraries) {
|
val hasNextUp = nextUp.isNotEmpty()
|
||||||
visibleLibraries.associate { it.id to FocusRequester() }
|
val hasLibraries = visibleLibraries.isNotEmpty()
|
||||||
}
|
val hasVisibleContent = hasContinueWatching || hasNextUp || hasLibraries
|
||||||
val initialFocusRequester = remember { FocusRequester() }
|
|
||||||
val firstVisibleLibraryId = visibleLibraries.firstOrNull()?.id
|
val firstVisibleLibraryId = visibleLibraries.firstOrNull()?.id
|
||||||
|
val initialFocusRequester = remember { FocusRequester() }
|
||||||
val firstAvailableItemKey = itemRegistry.firstAvailableItemId
|
val firstAvailableItemKey = itemRegistry.firstAvailableItemId
|
||||||
var initialFocusApplied by remember { mutableStateOf(false) }
|
var initialFocusApplied by remember { mutableStateOf(false) }
|
||||||
|
val topOffsetPx = with(LocalDensity.current) { TvHomeFocusedItemTopOffset.toPx() }
|
||||||
|
val columnBringIntoViewSpec = remember(topOffsetPx) {
|
||||||
|
tvHomeColumnBringIntoViewSpec(topOffsetPx = topOffsetPx)
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(firstAvailableItemKey, initialFocusApplied) {
|
LaunchedEffect(firstAvailableItemKey, initialFocusApplied) {
|
||||||
if (!initialFocusApplied && firstAvailableItemKey != null) {
|
if (!initialFocusApplied && firstAvailableItemKey != null) {
|
||||||
@@ -69,81 +76,90 @@ fun TvHomeContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CompositionLocalProvider(LocalBringIntoViewSpec provides columnBringIntoViewSpec) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(scheme.background),
|
.background(scheme.background)
|
||||||
|
.testTag(TvHomeContentViewportTag),
|
||||||
contentPadding = contentPadding
|
contentPadding = contentPadding
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
}
|
}
|
||||||
|
if (hasContinueWatching) {
|
||||||
item {
|
item {
|
||||||
TvContinueWatchingSection(
|
TvContinueWatchingSection(
|
||||||
items = continueWatching,
|
items = continueWatching,
|
||||||
onFocusedItem = onMediaFocused,
|
onFocusedItem = onMediaFocused,
|
||||||
onMovieSelected = onMovieSelected,
|
onMovieSelected = onMovieSelected,
|
||||||
onEpisodeSelected = onEpisodeSelected,
|
onEpisodeSelected = onEpisodeSelected,
|
||||||
firstItemFocusRequester = initialFocusRequester.takeIf { continueWatching.isNotEmpty() },
|
firstItemFocusRequester = initialFocusRequester,
|
||||||
firstItemTestTag = TvHomeInitialFocusTag.takeIf { continueWatching.isNotEmpty() },
|
firstItemTestTag = TvHomeInitialFocusTag,
|
||||||
modifier = Modifier.focusRequester(continueWatchingRef)
|
rowTestTag = TvHomeContinueWatchingRowTag
|
||||||
.focusProperties {
|
|
||||||
down = nextUpRef
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasContinueWatching && (hasNextUp || hasLibraries)) {
|
||||||
item {
|
item {
|
||||||
Spacer(modifier = Modifier.height(20.dp))
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasNextUp) {
|
||||||
item {
|
item {
|
||||||
TvNextUpSection(
|
TvNextUpSection(
|
||||||
items = nextUp,
|
items = nextUp,
|
||||||
onFocusedItem = onMediaFocused,
|
onFocusedItem = onMediaFocused,
|
||||||
onEpisodeSelected = onEpisodeSelected,
|
onEpisodeSelected = onEpisodeSelected,
|
||||||
firstItemFocusRequester = initialFocusRequester
|
firstItemFocusRequester = initialFocusRequester.takeIf { !hasContinueWatching },
|
||||||
.takeIf { continueWatching.isEmpty() && nextUp.isNotEmpty() },
|
firstItemTestTag = TvHomeInitialFocusTag.takeIf { !hasContinueWatching },
|
||||||
firstItemTestTag = TvHomeInitialFocusTag
|
rowTestTag = TvHomeNextUpRowTag
|
||||||
.takeIf { continueWatching.isEmpty() && nextUp.isNotEmpty() },
|
|
||||||
modifier = Modifier.focusRequester(nextUpRef)
|
|
||||||
.focusProperties {
|
|
||||||
up = continueWatchingRef
|
|
||||||
libraryRefs.values.firstOrNull()?.let { down = it }
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasLibraries && (hasContinueWatching || hasNextUp)) {
|
||||||
item {
|
item {
|
||||||
Spacer(modifier = Modifier.height(20.dp))
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
}
|
}
|
||||||
items(
|
}
|
||||||
|
|
||||||
|
itemsIndexed(
|
||||||
items = visibleLibraries,
|
items = visibleLibraries,
|
||||||
key = { it.id }
|
key = { _, library -> library.id }
|
||||||
) { item ->
|
) { index, library ->
|
||||||
val ref = libraryRefs[item.id]
|
|
||||||
TvLibraryPosterSection(
|
TvLibraryPosterSection(
|
||||||
title = item.name,
|
title = library.name,
|
||||||
items = libraryContent[item.id] ?: emptyList(),
|
items = libraryContent[library.id].orEmpty(),
|
||||||
onFocusedItem = onMediaFocused,
|
onFocusedItem = onMediaFocused,
|
||||||
onMovieSelected = onMovieSelected,
|
|
||||||
onSeriesSelected = onSeriesSelected,
|
|
||||||
onEpisodeSelected = onEpisodeSelected,
|
|
||||||
firstItemFocusRequester = initialFocusRequester.takeIf {
|
firstItemFocusRequester = initialFocusRequester.takeIf {
|
||||||
continueWatching.isEmpty() &&
|
!hasContinueWatching &&
|
||||||
nextUp.isEmpty() &&
|
!hasNextUp &&
|
||||||
item.id == firstVisibleLibraryId
|
library.id == firstVisibleLibraryId
|
||||||
},
|
},
|
||||||
firstItemTestTag = TvHomeInitialFocusTag.takeIf {
|
firstItemTestTag = TvHomeInitialFocusTag.takeIf {
|
||||||
continueWatching.isEmpty() &&
|
!hasContinueWatching &&
|
||||||
nextUp.isEmpty() &&
|
!hasNextUp &&
|
||||||
item.id == firstVisibleLibraryId
|
library.id == firstVisibleLibraryId
|
||||||
},
|
},
|
||||||
modifier = (if (ref != null) Modifier.focusRequester(ref) else Modifier)
|
rowTestTag = tvHomeLibraryRowTag(library.id),
|
||||||
.focusProperties {
|
onMovieSelected = onMovieSelected,
|
||||||
up = nextUpRef
|
onSeriesSelected = onSeriesSelected,
|
||||||
libraryRefs.values.dropWhile { it != ref }.drop(1).firstOrNull()
|
onEpisodeSelected = onEpisodeSelected
|
||||||
?.let { down = it }
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (index < visibleLibraries.lastIndex) {
|
||||||
Spacer(modifier = Modifier.height(20.dp))
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hasVisibleContent) {
|
||||||
|
item {
|
||||||
|
Spacer(modifier = Modifier.height(TvHomeBringIntoViewTrailingSpace))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package hu.bbara.purefin.tv.home.ui
|
package hu.bbara.purefin.tv.home.ui
|
||||||
|
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -15,12 +17,14 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.wrapContentHeight
|
import androidx.compose.foundation.layout.wrapContentHeight
|
||||||
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -57,6 +61,12 @@ private val TvHomeSectionsHorizontalPadding = 32.dp
|
|||||||
private val TvHomeSectionsRowSpacing = 18.dp
|
private val TvHomeSectionsRowSpacing = 18.dp
|
||||||
private val TvHomeLandscapeCardWidth = 248.dp
|
private val TvHomeLandscapeCardWidth = 248.dp
|
||||||
private val TvHomePosterCardWidth = 136.dp
|
private val TvHomePosterCardWidth = 136.dp
|
||||||
|
internal const val TvHomeSectionRowTagPrefix = "tv-home-section-row-"
|
||||||
|
internal const val TvHomeContinueWatchingRowTag = "${TvHomeSectionRowTagPrefix}continue-watching"
|
||||||
|
internal const val TvHomeNextUpRowTag = "${TvHomeSectionRowTagPrefix}next-up"
|
||||||
|
|
||||||
|
internal fun tvHomeLibraryRowTag(libraryId: UUID): String =
|
||||||
|
"${TvHomeSectionRowTagPrefix}library-$libraryId"
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TvContinueWatchingSection(
|
fun TvContinueWatchingSection(
|
||||||
@@ -66,16 +76,16 @@ fun TvContinueWatchingSection(
|
|||||||
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
|
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
|
||||||
firstItemFocusRequester: FocusRequester? = null,
|
firstItemFocusRequester: FocusRequester? = null,
|
||||||
firstItemTestTag: String? = null,
|
firstItemTestTag: String? = null,
|
||||||
|
rowTestTag: String? = null,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
if (items.isEmpty()) return
|
if (items.isEmpty()) return
|
||||||
TvSectionHeader(
|
TvSectionHeader(
|
||||||
title = "Continue Watching",
|
title = "Continue Watching",
|
||||||
)
|
)
|
||||||
LazyRow(
|
TvHomeSectionRow(
|
||||||
modifier = modifier.fillMaxWidth(),
|
modifier = modifier,
|
||||||
contentPadding = PaddingValues(horizontal = TvHomeSectionsHorizontalPadding),
|
rowTestTag = rowTestTag
|
||||||
horizontalArrangement = Arrangement.spacedBy(TvHomeSectionsRowSpacing)
|
|
||||||
) {
|
) {
|
||||||
itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
|
itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
|
||||||
val progressFraction = (item.progress / 100.0).toFloat().coerceIn(0f, 1f)
|
val progressFraction = (item.progress / 100.0).toFloat().coerceIn(0f, 1f)
|
||||||
@@ -135,16 +145,16 @@ fun TvNextUpSection(
|
|||||||
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
|
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
|
||||||
firstItemFocusRequester: FocusRequester? = null,
|
firstItemFocusRequester: FocusRequester? = null,
|
||||||
firstItemTestTag: String? = null,
|
firstItemTestTag: String? = null,
|
||||||
|
rowTestTag: String? = null,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
if (items.isEmpty()) return
|
if (items.isEmpty()) return
|
||||||
TvSectionHeader(
|
TvSectionHeader(
|
||||||
title = "Next Up",
|
title = "Next Up",
|
||||||
)
|
)
|
||||||
LazyRow(
|
TvHomeSectionRow(
|
||||||
modifier = modifier.fillMaxWidth(),
|
modifier = modifier,
|
||||||
contentPadding = PaddingValues(horizontal = TvHomeSectionsHorizontalPadding),
|
rowTestTag = rowTestTag
|
||||||
horizontalArrangement = Arrangement.spacedBy(TvHomeSectionsRowSpacing)
|
|
||||||
) {
|
) {
|
||||||
itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
|
itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
|
||||||
TvHomeLandscapeCard(
|
TvHomeLandscapeCard(
|
||||||
@@ -183,6 +193,7 @@ fun TvLibraryPosterSection(
|
|||||||
onFocusedItem: (FocusableItem) -> Unit = {},
|
onFocusedItem: (FocusableItem) -> Unit = {},
|
||||||
firstItemFocusRequester: FocusRequester? = null,
|
firstItemFocusRequester: FocusRequester? = null,
|
||||||
firstItemTestTag: String? = null,
|
firstItemTestTag: String? = null,
|
||||||
|
rowTestTag: String? = null,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onMovieSelected: (UUID) -> Unit,
|
onMovieSelected: (UUID) -> Unit,
|
||||||
onSeriesSelected: (UUID) -> Unit,
|
onSeriesSelected: (UUID) -> Unit,
|
||||||
@@ -191,10 +202,9 @@ fun TvLibraryPosterSection(
|
|||||||
TvSectionHeader(
|
TvSectionHeader(
|
||||||
title = title,
|
title = title,
|
||||||
)
|
)
|
||||||
LazyRow(
|
TvHomeSectionRow(
|
||||||
modifier = modifier.fillMaxWidth(),
|
modifier = modifier,
|
||||||
contentPadding = PaddingValues(horizontal = TvHomeSectionsHorizontalPadding),
|
rowTestTag = rowTestTag
|
||||||
horizontalArrangement = Arrangement.spacedBy(TvHomeSectionsRowSpacing)
|
|
||||||
) {
|
) {
|
||||||
itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
|
itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
|
||||||
PosterCard(
|
PosterCard(
|
||||||
@@ -227,6 +237,31 @@ fun TvLibraryPosterSection(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun TvHomeSectionRow(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
rowTestTag: String? = null,
|
||||||
|
content: LazyListScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
CompositionLocalProvider(LocalBringIntoViewSpec provides TvHomeRowBringIntoViewSpec) {
|
||||||
|
LazyRow(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.then(
|
||||||
|
if (rowTestTag != null) {
|
||||||
|
Modifier.testTag(rowTestTag)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
}
|
||||||
|
),
|
||||||
|
contentPadding = PaddingValues(horizontal = TvHomeSectionsHorizontalPadding),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(TvHomeSectionsRowSpacing),
|
||||||
|
content = content
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TvSectionHeader(
|
fun TvSectionHeader(
|
||||||
title: String,
|
title: String,
|
||||||
|
|||||||
@@ -623,11 +623,20 @@ private fun TvPlayerStateCard(
|
|||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = uiState.error ?: "Playback error",
|
text = uiState.error?.summary ?: "Playback error",
|
||||||
color = scheme.onBackground,
|
color = scheme.onBackground,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
style = MaterialTheme.typography.titleMedium
|
style = MaterialTheme.typography.titleMedium
|
||||||
)
|
)
|
||||||
|
uiState.error?.detailText?.let { detail ->
|
||||||
|
Text(
|
||||||
|
text = detail,
|
||||||
|
color = scheme.onBackground.copy(alpha = 0.82f),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
maxLines = 4,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
Button(onClick = onRetry) { Text("Retry") }
|
Button(onClick = onRetry) { Text("Retry") }
|
||||||
Button(
|
Button(
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import hu.bbara.purefin.core.player.model.PlayerUiState
|
import hu.bbara.purefin.core.player.model.PlayerUiState
|
||||||
@@ -46,10 +47,19 @@ fun PlayerLoadingErrorEndCard(
|
|||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = uiState.error ?: "Playback error",
|
text = uiState.error?.summary ?: "Playback error",
|
||||||
color = scheme.onBackground,
|
color = scheme.onBackground,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold
|
||||||
)
|
)
|
||||||
|
uiState.error?.detailText?.let { detail ->
|
||||||
|
Text(
|
||||||
|
text = detail,
|
||||||
|
color = scheme.onBackground.copy(alpha = 0.8f),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
maxLines = 4,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
Button(onClick = onRetry) {
|
Button(onClick = onRetry) {
|
||||||
Text("Retry")
|
Text("Retry")
|
||||||
|
|||||||
@@ -39,4 +39,5 @@ dependencies {
|
|||||||
implementation(libs.kotlinx.serialization.json)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
implementation(libs.jellyfin.core)
|
implementation(libs.jellyfin.core)
|
||||||
implementation(libs.okhttp)
|
implementation(libs.okhttp)
|
||||||
|
testImplementation(libs.junit)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package hu.bbara.purefin.core.player.data
|
||||||
|
|
||||||
|
import androidx.media3.common.MediaItem
|
||||||
|
import hu.bbara.purefin.core.player.model.PlayerError
|
||||||
|
|
||||||
|
sealed interface PlayerMediaLoadResult {
|
||||||
|
data class Success(
|
||||||
|
val mediaItem: MediaItem,
|
||||||
|
val resumePositionMs: Long?
|
||||||
|
) : PlayerMediaLoadResult
|
||||||
|
|
||||||
|
data class Failure(
|
||||||
|
val error: PlayerError
|
||||||
|
) : PlayerMediaLoadResult
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import hu.bbara.purefin.core.data.client.JellyfinApiClient
|
|||||||
import hu.bbara.purefin.core.data.client.PlaybackReportContext
|
import hu.bbara.purefin.core.data.client.PlaybackReportContext
|
||||||
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
|
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
|
||||||
import hu.bbara.purefin.core.data.session.UserSessionRepository
|
import hu.bbara.purefin.core.data.session.UserSessionRepository
|
||||||
|
import hu.bbara.purefin.core.player.model.PlayerError
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -34,9 +35,34 @@ class PlayerMediaRepository @Inject constructor(
|
|||||||
private val mediaRepository: MediaRepository,
|
private val mediaRepository: MediaRepository,
|
||||||
private val downloadManager: DownloadManager
|
private val downloadManager: DownloadManager
|
||||||
) {
|
) {
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "PlayerMediaRepo"
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun getMediaItem(mediaId: UUID, forceTranscode: Boolean = false): Pair<MediaItem, Long?>? = withContext(Dispatchers.IO) {
|
suspend fun getMediaItem(mediaId: UUID, forceTranscode: Boolean = false): PlayerMediaLoadResult = withContext(Dispatchers.IO) {
|
||||||
buildOnlineMediaItem(mediaId, forceTranscode) ?: buildOfflineMediaItem(mediaId)
|
when (val onlineResult = buildOnlineMediaItem(mediaId, forceTranscode)) {
|
||||||
|
is PlayerMediaLoadResult.Success -> onlineResult
|
||||||
|
is PlayerMediaLoadResult.Failure -> {
|
||||||
|
when (val offlineResult = buildOfflineMediaItem(mediaId)) {
|
||||||
|
is OfflineLoadResult.Success -> {
|
||||||
|
Log.w(
|
||||||
|
TAG,
|
||||||
|
"Using offline fallback for $mediaId after online load failure (forceTranscode=$forceTranscode): ${onlineResult.error.detailText ?: onlineResult.error.summary}"
|
||||||
|
)
|
||||||
|
offlineResult.result
|
||||||
|
}
|
||||||
|
|
||||||
|
is OfflineLoadResult.Unavailable -> {
|
||||||
|
val finalError = onlineResult.error.withAdditionalTechnicalDetail(offlineResult.detail)
|
||||||
|
Log.w(
|
||||||
|
TAG,
|
||||||
|
"Unable to resolve media $mediaId (forceTranscode=$forceTranscode): ${finalError.detailText ?: finalError.summary}"
|
||||||
|
)
|
||||||
|
PlayerMediaLoadResult.Failure(finalError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun calculateResumePosition(
|
private fun calculateResumePosition(
|
||||||
@@ -88,17 +114,25 @@ class PlayerMediaRepository @Inject constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}.getOrElse { error ->
|
}.getOrElse { error ->
|
||||||
Log.w("PlayerMediaRepo", "Unable to load next-up items for $episodeId", error)
|
Log.w(TAG, "Unable to load next-up items for $episodeId", error)
|
||||||
emptyList()
|
emptyList()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun buildOnlineMediaItem(mediaId: UUID, forceTranscode: Boolean): Pair<MediaItem, Long?>? {
|
private suspend fun buildOnlineMediaItem(mediaId: UUID, forceTranscode: Boolean): PlayerMediaLoadResult {
|
||||||
return runCatching {
|
return try {
|
||||||
val playbackDecision = jellyfinApiClient.getPlaybackDecision(
|
val playbackDecision = jellyfinApiClient.getPlaybackDecision(
|
||||||
mediaId = mediaId,
|
mediaId = mediaId,
|
||||||
forceTranscode = forceTranscode
|
forceTranscode = forceTranscode
|
||||||
) ?: return null
|
) ?: run {
|
||||||
|
val detail = if (forceTranscode) {
|
||||||
|
"Jellyfin did not return a transcoded playback source."
|
||||||
|
} else {
|
||||||
|
"Jellyfin did not return a playable media source."
|
||||||
|
}
|
||||||
|
Log.w(TAG, "No playback decision for $mediaId (forceTranscode=$forceTranscode)")
|
||||||
|
return PlayerMediaLoadResult.Failure(PlayerError.loadFailure(technicalDetail = detail))
|
||||||
|
}
|
||||||
val selectedMediaSource = playbackDecision.mediaSource
|
val selectedMediaSource = playbackDecision.mediaSource
|
||||||
val baseItem = jellyfinApiClient.getItemInfo(mediaId)
|
val baseItem = jellyfinApiClient.getItemInfo(mediaId)
|
||||||
|
|
||||||
@@ -117,18 +151,19 @@ class PlayerMediaRepository @Inject constructor(
|
|||||||
tag = playbackDecision.reportContext
|
tag = playbackDecision.reportContext
|
||||||
)
|
)
|
||||||
|
|
||||||
mediaItem to resumePositionMs
|
PlayerMediaLoadResult.Success(mediaItem, resumePositionMs)
|
||||||
}.getOrElse { error ->
|
} catch (error: Exception) {
|
||||||
Log.w("PlayerMediaRepo", "Falling back to offline playback for $mediaId", error)
|
Log.w(TAG, "Online load failed for $mediaId (forceTranscode=$forceTranscode)", error)
|
||||||
null
|
PlayerMediaLoadResult.Failure(PlayerError.fromThrowable(error))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
private suspend fun buildOfflineMediaItem(mediaId: UUID): Pair<MediaItem, Long?>? {
|
private suspend fun buildOfflineMediaItem(mediaId: UUID): OfflineLoadResult {
|
||||||
|
return try {
|
||||||
val download = downloadManager.downloadIndex.getDownload(mediaId.toString())
|
val download = downloadManager.downloadIndex.getDownload(mediaId.toString())
|
||||||
?.takeIf { it.state == Download.STATE_COMPLETED }
|
?.takeIf { it.state == Download.STATE_COMPLETED }
|
||||||
?: return null
|
?: return OfflineLoadResult.Unavailable("Offline fallback unavailable: no completed download.")
|
||||||
|
|
||||||
val serverUrl = userSessionRepository.serverUrl.first()
|
val serverUrl = userSessionRepository.serverUrl.first()
|
||||||
val movie = mediaRepository.movies.value[mediaId]
|
val movie = mediaRepository.movies.value[mediaId]
|
||||||
@@ -152,7 +187,14 @@ class PlayerMediaRepository @Inject constructor(
|
|||||||
subtitle = subtitle,
|
subtitle = subtitle,
|
||||||
artworkUrl = artworkUrl,
|
artworkUrl = artworkUrl,
|
||||||
)
|
)
|
||||||
return mediaItem to resumePositionMs
|
OfflineLoadResult.Success(PlayerMediaLoadResult.Success(mediaItem, resumePositionMs))
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.w(TAG, "Offline fallback failed for $mediaId", error)
|
||||||
|
val technicalDetail = PlayerError.fromThrowable(error).detailText ?: error.javaClass.simpleName
|
||||||
|
OfflineLoadResult.Unavailable(
|
||||||
|
"Offline fallback failed: $technicalDetail"
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun resumePositionFor(movie: hu.bbara.purefin.core.model.Movie?, episode: hu.bbara.purefin.core.model.Episode?): Long? {
|
private fun resumePositionFor(movie: hu.bbara.purefin.core.model.Movie?, episode: hu.bbara.purefin.core.model.Episode?): Long? {
|
||||||
@@ -211,7 +253,7 @@ class PlayerMediaRepository @Inject constructor(
|
|||||||
"$baseUrl/Videos/$mediaId/$mediaSourceId/Subtitles/${stream.index}/0/Stream.$format"
|
"$baseUrl/Videos/$mediaId/$mediaSourceId/Subtitles/${stream.index}/0/Stream.$format"
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.d("PlayerMediaRepo", "External subtitle: ${stream.displayTitle} ($codec) -> $url")
|
Log.d(TAG, "External subtitle: ${stream.displayTitle} ($codec) -> $url")
|
||||||
|
|
||||||
MediaItem.SubtitleConfiguration.Builder(url.toUri())
|
MediaItem.SubtitleConfiguration.Builder(url.toUri())
|
||||||
.setMimeType(mimeType)
|
.setMimeType(mimeType)
|
||||||
@@ -235,7 +277,7 @@ class PlayerMediaRepository @Inject constructor(
|
|||||||
"sub", "microdvd" -> MimeTypes.APPLICATION_SUBRIP // sub often converted to srt by Jellyfin
|
"sub", "microdvd" -> MimeTypes.APPLICATION_SUBRIP // sub often converted to srt by Jellyfin
|
||||||
"pgs", "pgssub" -> MimeTypes.APPLICATION_PGS
|
"pgs", "pgssub" -> MimeTypes.APPLICATION_PGS
|
||||||
else -> {
|
else -> {
|
||||||
Log.w("PlayerMediaRepo", "Unknown subtitle codec: $codec")
|
Log.w(TAG, "Unknown subtitle codec: $codec")
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -262,4 +304,9 @@ class PlayerMediaRepository @Inject constructor(
|
|||||||
.setSubtitleConfigurations(subtitleConfigurations)
|
.setSubtitleConfigurations(subtitleConfigurations)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed interface OfflineLoadResult {
|
||||||
|
data class Success(val result: PlayerMediaLoadResult.Success) : OfflineLoadResult
|
||||||
|
data class Unavailable(val detail: String) : OfflineLoadResult
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import androidx.media3.common.Tracks
|
|||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import dagger.hilt.android.scopes.ViewModelScoped
|
import dagger.hilt.android.scopes.ViewModelScoped
|
||||||
import hu.bbara.purefin.core.data.client.PlaybackReportContext
|
import hu.bbara.purefin.core.data.client.PlaybackReportContext
|
||||||
|
import hu.bbara.purefin.core.player.model.PlayerError
|
||||||
import hu.bbara.purefin.core.player.model.QueueItemUi
|
import hu.bbara.purefin.core.player.model.QueueItemUi
|
||||||
import hu.bbara.purefin.core.player.model.TrackOption
|
import hu.bbara.purefin.core.player.model.TrackOption
|
||||||
import hu.bbara.purefin.core.player.model.TrackType
|
import hu.bbara.purefin.core.player.model.TrackType
|
||||||
@@ -74,8 +75,7 @@ class PlayerManager @Inject constructor(
|
|||||||
state.copy(
|
state.copy(
|
||||||
isBuffering = buffering,
|
isBuffering = buffering,
|
||||||
isEnded = ended,
|
isEnded = ended,
|
||||||
error = if (playbackState == Player.STATE_IDLE) state.error else null,
|
error = if (playbackState == Player.STATE_IDLE) state.error else null
|
||||||
errorCode = if (playbackState == Player.STATE_IDLE) state.errorCode else null
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (ended) player.pause()
|
if (ended) player.pause()
|
||||||
@@ -84,8 +84,7 @@ class PlayerManager @Inject constructor(
|
|||||||
override fun onPlayerError(error: PlaybackException) {
|
override fun onPlayerError(error: PlaybackException) {
|
||||||
_playbackState.update {
|
_playbackState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
error = error.errorCodeName ?: error.localizedMessage ?: "Playback error",
|
error = PlayerError.fromPlaybackException(error)
|
||||||
errorCode = error.errorCode
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,7 +134,7 @@ class PlayerManager @Inject constructor(
|
|||||||
_progress.value = PlaybackProgressSnapshot()
|
_progress.value = PlaybackProgressSnapshot()
|
||||||
refreshMetadata(mediaItem)
|
refreshMetadata(mediaItem)
|
||||||
refreshQueue()
|
refreshQueue()
|
||||||
_playbackState.update { it.copy(isEnded = false, error = null, errorCode = null) }
|
_playbackState.update { it.copy(isEnded = false, error = null) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun replaceCurrentMediaItem(mediaItem: MediaItem, mediaContext: MediaContext? = null, startPositionMs: Long? = null) {
|
fun replaceCurrentMediaItem(mediaItem: MediaItem, mediaContext: MediaContext? = null, startPositionMs: Long? = null) {
|
||||||
@@ -155,7 +154,7 @@ class PlayerManager @Inject constructor(
|
|||||||
player.playWhenReady = true
|
player.playWhenReady = true
|
||||||
refreshMetadata(mediaItem)
|
refreshMetadata(mediaItem)
|
||||||
refreshQueue()
|
refreshQueue()
|
||||||
_playbackState.update { it.copy(isEnded = false, error = null, errorCode = null) }
|
_playbackState.update { it.copy(isEnded = false, error = null) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addToQueue(mediaItem: MediaItem) {
|
fun addToQueue(mediaItem: MediaItem) {
|
||||||
@@ -259,7 +258,7 @@ class PlayerManager @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun clearError() {
|
fun clearError() {
|
||||||
_playbackState.update { it.copy(error = null, errorCode = null) }
|
_playbackState.update { it.copy(error = null) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun snapshotProgress(): PlaybackProgressSnapshot {
|
fun snapshotProgress(): PlaybackProgressSnapshot {
|
||||||
@@ -386,8 +385,7 @@ data class PlaybackStateSnapshot(
|
|||||||
val isPlaying: Boolean = false,
|
val isPlaying: Boolean = false,
|
||||||
val isBuffering: Boolean = false,
|
val isBuffering: Boolean = false,
|
||||||
val isEnded: Boolean = false,
|
val isEnded: Boolean = false,
|
||||||
val error: String? = null,
|
val error: PlayerError? = null
|
||||||
val errorCode: Int? = null
|
|
||||||
)
|
)
|
||||||
|
|
||||||
data class PlaybackProgressSnapshot(
|
data class PlaybackProgressSnapshot(
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package hu.bbara.purefin.core.player.model
|
||||||
|
|
||||||
|
import androidx.media3.common.PlaybackException
|
||||||
|
|
||||||
|
enum class PlayerErrorSource {
|
||||||
|
LOAD,
|
||||||
|
PLAYBACK
|
||||||
|
}
|
||||||
|
|
||||||
|
data class PlayerError(
|
||||||
|
val summary: String,
|
||||||
|
val technicalDetail: String? = null,
|
||||||
|
val source: PlayerErrorSource,
|
||||||
|
val errorCode: Int? = null,
|
||||||
|
val errorCodeName: String? = null,
|
||||||
|
val retryable: Boolean = false
|
||||||
|
) {
|
||||||
|
val detailText: String?
|
||||||
|
get() = technicalDetail?.takeIf { detail ->
|
||||||
|
detail.isNotBlank() && !detail.equals(summary, ignoreCase = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun withAdditionalTechnicalDetail(additionalDetail: String?): PlayerError {
|
||||||
|
val normalizedAdditionalDetail = additionalDetail?.trim()?.takeIf { it.isNotEmpty() } ?: return this
|
||||||
|
val mergedDetail = linkedSetOf<String>().apply {
|
||||||
|
technicalDetail?.trim()?.takeIf { it.isNotEmpty() }?.let(::add)
|
||||||
|
add(normalizedAdditionalDetail)
|
||||||
|
}.joinToString(" | ")
|
||||||
|
|
||||||
|
return copy(technicalDetail = mergedDetail)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun loadFailure(
|
||||||
|
summary: String = "Unable to load media",
|
||||||
|
technicalDetail: String? = null,
|
||||||
|
retryable: Boolean = true
|
||||||
|
): PlayerError {
|
||||||
|
return PlayerError(
|
||||||
|
summary = summary,
|
||||||
|
technicalDetail = technicalDetail,
|
||||||
|
source = PlayerErrorSource.LOAD,
|
||||||
|
retryable = retryable
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun invalidMediaId(mediaId: String): PlayerError {
|
||||||
|
return PlayerError(
|
||||||
|
summary = "Invalid media id",
|
||||||
|
technicalDetail = "The requested media id is not a valid UUID: $mediaId",
|
||||||
|
source = PlayerErrorSource.LOAD,
|
||||||
|
retryable = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun fromPlaybackException(error: PlaybackException): PlayerError {
|
||||||
|
return playbackFailure(
|
||||||
|
errorCode = error.errorCode,
|
||||||
|
errorCodeName = error.errorCodeName,
|
||||||
|
technicalDetail = error.localizedMessage,
|
||||||
|
cause = error.cause
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun playbackFailure(
|
||||||
|
errorCode: Int? = null,
|
||||||
|
errorCodeName: String? = null,
|
||||||
|
technicalDetail: String? = null,
|
||||||
|
cause: Throwable? = null,
|
||||||
|
retryable: Boolean = true
|
||||||
|
): PlayerError {
|
||||||
|
val mergedTechnicalDetail = linkedSetOf<String>().apply {
|
||||||
|
errorCodeName?.takeIf { it.isNotBlank() }?.let(::add)
|
||||||
|
technicalDetail?.takeIf { it.isNotBlank() }?.let(::add)
|
||||||
|
cause?.toTechnicalDetail()?.let(::add)
|
||||||
|
}.joinToString(" | ").ifBlank { null }
|
||||||
|
|
||||||
|
return PlayerError(
|
||||||
|
summary = "Playback error",
|
||||||
|
technicalDetail = mergedTechnicalDetail,
|
||||||
|
source = PlayerErrorSource.PLAYBACK,
|
||||||
|
errorCode = errorCode,
|
||||||
|
errorCodeName = errorCodeName,
|
||||||
|
retryable = retryable
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun fromThrowable(
|
||||||
|
throwable: Throwable,
|
||||||
|
summary: String = "Unable to load media",
|
||||||
|
retryable: Boolean = true
|
||||||
|
): PlayerError {
|
||||||
|
return loadFailure(
|
||||||
|
summary = summary,
|
||||||
|
technicalDetail = throwable.toTechnicalDetail(),
|
||||||
|
retryable = retryable
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun Throwable.toTechnicalDetail(): String {
|
||||||
|
val typeName = this::class.simpleName ?: javaClass.simpleName.ifBlank { javaClass.name }
|
||||||
|
val message = message?.trim().takeIf { !it.isNullOrEmpty() }
|
||||||
|
return if (message != null) {
|
||||||
|
"$typeName: $message"
|
||||||
|
} else {
|
||||||
|
typeName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ data class PlayerUiState(
|
|||||||
val durationMs: Long = 0L,
|
val durationMs: Long = 0L,
|
||||||
val positionMs: Long = 0L,
|
val positionMs: Long = 0L,
|
||||||
val bufferedMs: Long = 0L,
|
val bufferedMs: Long = 0L,
|
||||||
val error: String? = null,
|
val error: PlayerError? = null,
|
||||||
val playbackSpeed: Float = 1f,
|
val playbackSpeed: Float = 1f,
|
||||||
val chapters: List<TimedMarker> = emptyList(),
|
val chapters: List<TimedMarker> = emptyList(),
|
||||||
val ads: List<TimedMarker> = emptyList(),
|
val ads: List<TimedMarker> = emptyList(),
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package hu.bbara.purefin.core.player.viewmodel
|
||||||
|
|
||||||
|
import androidx.media3.common.PlaybackException
|
||||||
|
import hu.bbara.purefin.core.data.client.PlaybackReportContext
|
||||||
|
import hu.bbara.purefin.core.player.model.PlayerError
|
||||||
|
import hu.bbara.purefin.core.player.model.PlayerErrorSource
|
||||||
|
import org.jellyfin.sdk.model.api.PlayMethod
|
||||||
|
|
||||||
|
internal object PlaybackRetryPolicy {
|
||||||
|
private val retryableErrorCodes = setOf(
|
||||||
|
PlaybackException.ERROR_CODE_DECODER_INIT_FAILED,
|
||||||
|
PlaybackException.ERROR_CODE_DECODER_QUERY_FAILED,
|
||||||
|
PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED,
|
||||||
|
PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED
|
||||||
|
)
|
||||||
|
|
||||||
|
fun shouldRetryWithTranscoding(
|
||||||
|
error: PlayerError,
|
||||||
|
playbackReportContext: PlaybackReportContext
|
||||||
|
): Boolean {
|
||||||
|
if (error.source != PlayerErrorSource.PLAYBACK) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!playbackReportContext.canRetryWithTranscoding) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (playbackReportContext.playMethod == PlayMethod.TRANSCODE) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
val errorCode = error.errorCode
|
||||||
|
if (errorCode != null && errorCode in retryableErrorCodes) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
val detail = buildString {
|
||||||
|
append(error.summary)
|
||||||
|
error.errorCodeName?.let {
|
||||||
|
append(' ')
|
||||||
|
append(it)
|
||||||
|
}
|
||||||
|
error.detailText?.let {
|
||||||
|
append(' ')
|
||||||
|
append(it)
|
||||||
|
}
|
||||||
|
}.lowercase()
|
||||||
|
|
||||||
|
return "decoder" in detail || "codec" in detail || "unsupported" in detail
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +1,20 @@
|
|||||||
package hu.bbara.purefin.core.player.viewmodel
|
package hu.bbara.purefin.core.player.viewmodel
|
||||||
|
|
||||||
import androidx.media3.common.PlaybackException
|
|
||||||
import androidx.lifecycle.SavedStateHandle
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import hu.bbara.purefin.core.data.MediaRepository
|
import hu.bbara.purefin.core.data.MediaRepository
|
||||||
import hu.bbara.purefin.core.data.client.PlaybackReportContext
|
import hu.bbara.purefin.core.player.data.PlayerMediaLoadResult
|
||||||
import hu.bbara.purefin.core.player.data.PlayerMediaRepository
|
import hu.bbara.purefin.core.player.data.PlayerMediaRepository
|
||||||
import hu.bbara.purefin.core.player.manager.MediaContext
|
import hu.bbara.purefin.core.player.manager.MediaContext
|
||||||
import hu.bbara.purefin.core.player.manager.PlaybackStateSnapshot
|
import hu.bbara.purefin.core.player.manager.PlaybackStateSnapshot
|
||||||
import hu.bbara.purefin.core.player.manager.PlayerManager
|
import hu.bbara.purefin.core.player.manager.PlayerManager
|
||||||
import hu.bbara.purefin.core.player.manager.ProgressManager
|
import hu.bbara.purefin.core.player.manager.ProgressManager
|
||||||
|
import hu.bbara.purefin.core.player.model.PlayerError
|
||||||
|
import hu.bbara.purefin.core.player.model.PlayerErrorSource
|
||||||
import hu.bbara.purefin.core.player.model.PlayerUiState
|
import hu.bbara.purefin.core.player.model.PlayerUiState
|
||||||
import hu.bbara.purefin.core.player.model.TrackOption
|
import hu.bbara.purefin.core.player.model.TrackOption
|
||||||
import org.jellyfin.sdk.model.api.PlayMethod
|
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -46,9 +46,10 @@ class PlayerViewModel @Inject constructor(
|
|||||||
|
|
||||||
private var autoHideJob: Job? = null
|
private var autoHideJob: Job? = null
|
||||||
private var lastNextUpMediaId: String? = null
|
private var lastNextUpMediaId: String? = null
|
||||||
private var dataErrorMessage: String? = null
|
private var loadError: PlayerError? = null
|
||||||
private var activeMediaId: String? = null
|
private var activeMediaId: String? = null
|
||||||
private var transcodingRetryMediaId: String? = null
|
private var transcodingRetryMediaId: String? = null
|
||||||
|
private var lastLoadRequest: PendingLoadRequest? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
progressManager.bind(
|
progressManager.bind(
|
||||||
@@ -72,7 +73,7 @@ class PlayerViewModel @Inject constructor(
|
|||||||
isPlaying = state.isPlaying,
|
isPlaying = state.isPlaying,
|
||||||
isBuffering = state.isBuffering,
|
isBuffering = state.isBuffering,
|
||||||
isEnded = state.isEnded,
|
isEnded = state.isEnded,
|
||||||
error = state.error ?: dataErrorMessage
|
error = state.error ?: loadError
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (state.isEnded) {
|
if (state.isEnded) {
|
||||||
@@ -156,16 +157,26 @@ class PlayerViewModel @Inject constructor(
|
|||||||
startPositionMsOverride: Long?,
|
startPositionMsOverride: Long?,
|
||||||
replaceCurrent: Boolean
|
replaceCurrent: Boolean
|
||||||
) {
|
) {
|
||||||
|
lastLoadRequest = PendingLoadRequest(
|
||||||
|
id = id,
|
||||||
|
forceTranscode = forceTranscode,
|
||||||
|
startPositionMsOverride = startPositionMsOverride,
|
||||||
|
replaceCurrent = replaceCurrent
|
||||||
|
)
|
||||||
val uuid = id.toUuidOrNull()
|
val uuid = id.toUuidOrNull()
|
||||||
if (uuid == null) {
|
if (uuid == null) {
|
||||||
dataErrorMessage = "Invalid media id"
|
loadError = PlayerError.invalidMediaId(id)
|
||||||
_uiState.update { it.copy(error = dataErrorMessage) }
|
_uiState.update { it.copy(isBuffering = false, error = loadError) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
loadError = null
|
||||||
|
_uiState.update { it.copy(isBuffering = true, error = null) }
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val result = playerMediaRepository.getMediaItem(uuid, forceTranscode = forceTranscode)
|
val result = playerMediaRepository.getMediaItem(uuid, forceTranscode = forceTranscode)
|
||||||
if (result != null) {
|
when (result) {
|
||||||
val (mediaItem, resumePositionMs) = result
|
is PlayerMediaLoadResult.Success -> {
|
||||||
|
val mediaItem = result.mediaItem
|
||||||
|
val resumePositionMs = result.resumePositionMs
|
||||||
|
|
||||||
// Determine preference key: movies use their own ID, episodes use series ID
|
// Determine preference key: movies use their own ID, episodes use series ID
|
||||||
val preferenceKey = mediaRepository.episodes.value[uuid]?.seriesId?.toString() ?: id
|
val preferenceKey = mediaRepository.episodes.value[uuid]?.seriesId?.toString() ?: id
|
||||||
@@ -178,13 +189,16 @@ class PlayerViewModel @Inject constructor(
|
|||||||
playerManager.play(mediaItem, mediaContext, startPositionMs)
|
playerManager.play(mediaItem, mediaContext, startPositionMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dataErrorMessage != null) {
|
if (loadError != null) {
|
||||||
dataErrorMessage = null
|
loadError = null
|
||||||
_uiState.update { it.copy(error = null) }
|
_uiState.update { it.copy(error = null) }
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
dataErrorMessage = "Unable to load media"
|
|
||||||
_uiState.update { it.copy(error = dataErrorMessage) }
|
is PlayerMediaLoadResult.Failure -> {
|
||||||
|
loadError = result.error
|
||||||
|
_uiState.update { it.copy(isBuffering = false, error = loadError) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -192,11 +206,10 @@ class PlayerViewModel @Inject constructor(
|
|||||||
private fun maybeRetryWithTranscoding(state: PlaybackStateSnapshot): Boolean {
|
private fun maybeRetryWithTranscoding(state: PlaybackStateSnapshot): Boolean {
|
||||||
val currentMediaId = playerManager.metadata.value.mediaId ?: return false
|
val currentMediaId = playerManager.metadata.value.mediaId ?: return false
|
||||||
val playbackReportContext = playerManager.metadata.value.playbackReportContext ?: return false
|
val playbackReportContext = playerManager.metadata.value.playbackReportContext ?: return false
|
||||||
val errorCode = state.errorCode ?: return false
|
val error = state.error ?: return false
|
||||||
|
|
||||||
if (currentMediaId == transcodingRetryMediaId) return false
|
if (currentMediaId == transcodingRetryMediaId) return false
|
||||||
if (!playbackReportContext.canRetryWithTranscoding) return false
|
if (!PlaybackRetryPolicy.shouldRetryWithTranscoding(error, playbackReportContext)) return false
|
||||||
if (!isRetryablePlaybackError(errorCode, state.error, playbackReportContext)) return false
|
|
||||||
|
|
||||||
transcodingRetryMediaId = currentMediaId
|
transcodingRetryMediaId = currentMediaId
|
||||||
loadMediaById(
|
loadMediaById(
|
||||||
@@ -208,29 +221,6 @@ class PlayerViewModel @Inject constructor(
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun isRetryablePlaybackError(
|
|
||||||
errorCode: Int,
|
|
||||||
errorMessage: String?,
|
|
||||||
playbackReportContext: PlaybackReportContext
|
|
||||||
): Boolean {
|
|
||||||
if (playbackReportContext.playMethod == PlayMethod.TRANSCODE) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (errorCode in setOf(
|
|
||||||
PlaybackException.ERROR_CODE_DECODER_INIT_FAILED,
|
|
||||||
PlaybackException.ERROR_CODE_DECODER_QUERY_FAILED,
|
|
||||||
PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED,
|
|
||||||
PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
val message = errorMessage?.lowercase().orEmpty()
|
|
||||||
return "decoder" in message || "codec" in message || "unsupported" in message
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun loadNextUp(currentMediaId: String) {
|
private fun loadNextUp(currentMediaId: String) {
|
||||||
val uuid = currentMediaId.toUuidOrNull() ?: return
|
val uuid = currentMediaId.toUuidOrNull() ?: return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -304,6 +294,11 @@ class PlayerViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun retry() {
|
fun retry() {
|
||||||
|
val error = uiState.value.error
|
||||||
|
if (error?.source == PlayerErrorSource.LOAD) {
|
||||||
|
retryLoad()
|
||||||
|
return
|
||||||
|
}
|
||||||
playerManager.retry()
|
playerManager.retry()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,7 +308,7 @@ class PlayerViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun clearError() {
|
fun clearError() {
|
||||||
dataErrorMessage = null
|
loadError = null
|
||||||
playerManager.clearError()
|
playerManager.clearError()
|
||||||
_uiState.update { it.copy(error = null) }
|
_uiState.update { it.copy(error = null) }
|
||||||
}
|
}
|
||||||
@@ -326,5 +321,22 @@ class PlayerViewModel @Inject constructor(
|
|||||||
playerManager.release()
|
playerManager.release()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun retryLoad() {
|
||||||
|
val request = lastLoadRequest ?: return
|
||||||
|
loadMediaById(
|
||||||
|
id = request.id,
|
||||||
|
forceTranscode = request.forceTranscode,
|
||||||
|
startPositionMsOverride = request.startPositionMsOverride,
|
||||||
|
replaceCurrent = request.replaceCurrent
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun String.toUuidOrNull(): UUID? = runCatching { UUID.fromString(this) }.getOrNull()
|
private fun String.toUuidOrNull(): UUID? = runCatching { UUID.fromString(this) }.getOrNull()
|
||||||
|
|
||||||
|
private data class PendingLoadRequest(
|
||||||
|
val id: String,
|
||||||
|
val forceTranscode: Boolean,
|
||||||
|
val startPositionMsOverride: Long?,
|
||||||
|
val replaceCurrent: Boolean
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package hu.bbara.purefin.core.player.model
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class PlayerErrorTest {
|
||||||
|
@Test
|
||||||
|
fun `playbackFailure preserves code and detail`() {
|
||||||
|
val playerError = PlayerError.playbackFailure(
|
||||||
|
errorCode = 4001,
|
||||||
|
errorCodeName = "ERROR_CODE_DECODER_INIT_FAILED",
|
||||||
|
technicalDetail = "Decoder init failed on test device"
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("Playback error", playerError.summary)
|
||||||
|
assertEquals(PlayerErrorSource.PLAYBACK, playerError.source)
|
||||||
|
assertEquals(4001, playerError.errorCode)
|
||||||
|
assertEquals("ERROR_CODE_DECODER_INIT_FAILED", playerError.errorCodeName)
|
||||||
|
assertNotNull(playerError.detailText)
|
||||||
|
assertTrue(playerError.detailText!!.contains("ERROR_CODE_DECODER_INIT_FAILED"))
|
||||||
|
assertTrue(playerError.detailText!!.contains("Decoder init failed on test device"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `withAdditionalTechnicalDetail appends distinct detail`() {
|
||||||
|
val playerError = PlayerError.loadFailure(technicalDetail = "IllegalStateException: boom")
|
||||||
|
|
||||||
|
val mergedError = playerError.withAdditionalTechnicalDetail(
|
||||||
|
"Offline fallback unavailable: no completed download."
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(mergedError.detailText!!.contains("IllegalStateException: boom"))
|
||||||
|
assertTrue(mergedError.detailText!!.contains("Offline fallback unavailable: no completed download."))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `invalidMediaId is a non retryable load error`() {
|
||||||
|
val playerError = PlayerError.invalidMediaId("abc")
|
||||||
|
|
||||||
|
assertEquals("Invalid media id", playerError.summary)
|
||||||
|
assertEquals(PlayerErrorSource.LOAD, playerError.source)
|
||||||
|
assertFalse(playerError.retryable)
|
||||||
|
assertTrue(playerError.detailText!!.contains("abc"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package hu.bbara.purefin.core.player.viewmodel
|
||||||
|
|
||||||
|
import androidx.media3.common.PlaybackException
|
||||||
|
import hu.bbara.purefin.core.data.client.PlaybackReportContext
|
||||||
|
import hu.bbara.purefin.core.player.model.PlayerError
|
||||||
|
import hu.bbara.purefin.core.player.model.PlayerErrorSource
|
||||||
|
import org.jellyfin.sdk.model.api.PlayMethod
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class PlaybackRetryPolicyTest {
|
||||||
|
@Test
|
||||||
|
fun `decoder failures are retried when transcoding is available`() {
|
||||||
|
val error = PlayerError(
|
||||||
|
summary = "Playback error",
|
||||||
|
technicalDetail = "Decoder init failed",
|
||||||
|
source = PlayerErrorSource.PLAYBACK,
|
||||||
|
errorCode = PlaybackException.ERROR_CODE_DECODER_INIT_FAILED,
|
||||||
|
errorCodeName = "ERROR_CODE_DECODER_INIT_FAILED",
|
||||||
|
retryable = true
|
||||||
|
)
|
||||||
|
|
||||||
|
val playbackReportContext = playbackReportContext(playMethod = PlayMethod.DIRECT_PLAY)
|
||||||
|
|
||||||
|
assertTrue(PlaybackRetryPolicy.shouldRetryWithTranscoding(error, playbackReportContext))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `transcoded playback is not retried again`() {
|
||||||
|
val error = PlayerError(
|
||||||
|
summary = "Playback error",
|
||||||
|
technicalDetail = "Unsupported container",
|
||||||
|
source = PlayerErrorSource.PLAYBACK,
|
||||||
|
errorCodeName = "ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED",
|
||||||
|
retryable = true
|
||||||
|
)
|
||||||
|
|
||||||
|
val playbackReportContext = playbackReportContext(playMethod = PlayMethod.TRANSCODE)
|
||||||
|
|
||||||
|
assertFalse(PlaybackRetryPolicy.shouldRetryWithTranscoding(error, playbackReportContext))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `load errors are never retried as transcoding fallbacks`() {
|
||||||
|
val error = PlayerError.loadFailure(technicalDetail = "No playable source was returned.")
|
||||||
|
|
||||||
|
val playbackReportContext = playbackReportContext(playMethod = PlayMethod.DIRECT_PLAY)
|
||||||
|
|
||||||
|
assertFalse(PlaybackRetryPolicy.shouldRetryWithTranscoding(error, playbackReportContext))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unsupported codec detail is retryable without an error code`() {
|
||||||
|
val error = PlayerError(
|
||||||
|
summary = "Playback error",
|
||||||
|
technicalDetail = "Codec unsupported by this device",
|
||||||
|
source = PlayerErrorSource.PLAYBACK
|
||||||
|
)
|
||||||
|
|
||||||
|
val playbackReportContext = playbackReportContext(playMethod = PlayMethod.DIRECT_STREAM)
|
||||||
|
|
||||||
|
assertTrue(PlaybackRetryPolicy.shouldRetryWithTranscoding(error, playbackReportContext))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun playbackReportContext(playMethod: PlayMethod): PlaybackReportContext {
|
||||||
|
return PlaybackReportContext(
|
||||||
|
playMethod = playMethod,
|
||||||
|
mediaSourceId = "source-id",
|
||||||
|
audioStreamIndex = 0,
|
||||||
|
subtitleStreamIndex = null,
|
||||||
|
liveStreamId = null,
|
||||||
|
playSessionId = "session-id",
|
||||||
|
canRetryWithTranscoding = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user