mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-23 19:26:50 +00:00
Compare commits
77 Commits
ef56db5d16
...
80f25c6dea
| Author | SHA1 | Date | |
|---|---|---|---|
| 80f25c6dea | |||
| b19d41abc6 | |||
| 831abfebb4 | |||
| eb540a0fbb | |||
| 29c48a50b6 | |||
| 05f58295ad | |||
| 31f9fe5d4c | |||
| 1ec9ff9c95 | |||
| 5491c381a3 | |||
| 34340c2bd4 | |||
| f6c465656e | |||
| 54c895ff0f | |||
| a4267d85ce | |||
| a07f9dd6e4 | |||
| 50bbf5b81f | |||
| 1bd51d8a9f | |||
| 615e95db5c | |||
| ccd5a7f50e | |||
| d0149bf647 | |||
| 9bea9a81ff | |||
| 8d74d0c5df | |||
| e78a3700a6 | |||
| def92ad111 | |||
| c92a8831a5 | |||
| fe66926335 | |||
| 4727cdcab1 | |||
| 2853609cbb | |||
| 327e68af19 | |||
| 8dcb6aeea9 | |||
| df2a468e89 | |||
| 4a6977eefb | |||
| 3f9fd6e36e | |||
| 10e7b450f3 | |||
| c81b228bb2 | |||
| f94bdc5bce | |||
| 4ade70a5de | |||
| ce907cee0c | |||
| dd66302da0 | |||
| 753b461453 | |||
| 0bf968a42b | |||
| 005b3249ff | |||
| 84157868d0 | |||
| 25b65b9048 | |||
| ed418a8e44 | |||
| fecc962950 | |||
| 1ae2541773 | |||
| 88e8b3a41f | |||
| 85c53f8704 | |||
| 448550b5ab | |||
| 9f8292b04d | |||
| fa87005d01 | |||
| 1ad7bc4e23 | |||
| e72c421294 | |||
| 9aec271aa4 | |||
| 717bf3c8e1 | |||
| 0011f3842a | |||
| 670e937566 | |||
| 38f1f94956 | |||
| 72cb574753 | |||
| 80ab591662 | |||
| 0eea3165de | |||
| b1b154800c | |||
| 5c1ddffe7a | |||
| 97dcea57ec | |||
| 09a1bb2288 | |||
| 8064367e7a | |||
| dedd4f1da5 | |||
| e58afd26d5 | |||
| 15c1e806da | |||
| 194052517b | |||
| b0ed32ef7c | |||
| 3cd706d449 | |||
| 1dedc7ff0a | |||
| 285fda9caf | |||
| 3e94e8193a | |||
|
|
0a56f46da7 | ||
| b226eaeba2 |
@@ -57,7 +57,9 @@ dependencies {
|
||||
implementation(project(":core"))
|
||||
implementation(project(":core-model"))
|
||||
implementation(project(":core-ui"))
|
||||
implementation(libs.androidx.compose.animation)
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.foundation)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
|
||||
@@ -1,979 +0,0 @@
|
||||
package hu.bbara.purefin.ui.screen.player.components
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.semantics.SemanticsActions
|
||||
import androidx.compose.ui.semantics.SemanticsProperties
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.SemanticsMatcher
|
||||
import androidx.compose.ui.test.assert
|
||||
import androidx.compose.ui.test.assertCountEquals
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertIsFocused
|
||||
import androidx.compose.ui.test.assertIsNotEnabled
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithContentDescription
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performKeyInput
|
||||
import androidx.compose.ui.test.performSemanticsAction
|
||||
import androidx.compose.ui.test.pressKey
|
||||
import androidx.compose.ui.unit.dp
|
||||
import hu.bbara.purefin.core.player.model.PlayerUiState
|
||||
import hu.bbara.purefin.core.player.model.PlaylistElementUiModel
|
||||
import hu.bbara.purefin.core.player.model.TrackOption
|
||||
import hu.bbara.purefin.core.player.model.TrackType
|
||||
import hu.bbara.purefin.ui.screen.player.TV_HIDDEN_STOP_FEEDBACK_MS
|
||||
import hu.bbara.purefin.ui.screen.player.TvPlayerHiddenStopFeedback
|
||||
import hu.bbara.purefin.ui.screen.player.TvPlayerHiddenStopFeedbackTag
|
||||
import hu.bbara.purefin.ui.screen.player.handleTvPlayerRootKeyEvent
|
||||
import hu.bbara.purefin.ui.theme.AppTheme
|
||||
import kotlinx.coroutines.delay
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
private const val TvPlayerRootTag = "tv_player_root"
|
||||
private const val TvPlayerHiddenPauseCountTag = "tv_player_hidden_pause_count"
|
||||
private const val TvPlayerHiddenResumeCountTag = "tv_player_hidden_resume_count"
|
||||
private const val TvPlayerHiddenSeekDeltaTag = "tv_player_hidden_seek_delta"
|
||||
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
class TvPlayerControlsOverlayTest {
|
||||
|
||||
@get:Rule
|
||||
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun requestingControlsFocus_focusesPlayPauseInsteadOfSeekBar() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
OverlayHost(
|
||||
uiState = samplePlayerState(),
|
||||
requestFocus = true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerPlayPauseButtonTag).assertIsFocused()
|
||||
composeRule.onNodeWithTag(TvPlayerSeekBarTag)
|
||||
.assert(
|
||||
SemanticsMatcher.expectValue(
|
||||
SemanticsProperties.Focused,
|
||||
false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun downFromSeekBar_movesFocusToControlButtonsBeforePlaylist() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
OverlayHost(uiState = samplePlayerState())
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerSeekBarTag)
|
||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
||||
.assertIsFocused()
|
||||
.performKeyInput {
|
||||
pressKey(Key.DirectionDown)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerPlaylistStateTag)
|
||||
.assert(
|
||||
SemanticsMatcher.expectValue(
|
||||
SemanticsProperties.StateDescription,
|
||||
"collapsed"
|
||||
)
|
||||
)
|
||||
composeRule.onNodeWithTag(TvPlayerPlayPauseButtonTag).assertIsFocused()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun downFromControls_expandsPlaylistAndMovesFocusToCurrentQueueItem() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
OverlayHost(uiState = samplePlayerState())
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerPlayPauseButtonTag)
|
||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
||||
.assertIsFocused()
|
||||
.performKeyInput {
|
||||
pressKey(Key.DirectionDown)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerPlaylistStateTag)
|
||||
.assert(
|
||||
SemanticsMatcher.expectValue(
|
||||
SemanticsProperties.StateDescription,
|
||||
"expanded"
|
||||
)
|
||||
)
|
||||
composeRule.onNodeWithTag(TvPlayerPlaylistCurrentItemTag).assertIsFocused()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun upFromCurrentQueueItem_collapsesPlaylistAndRestoresFocusedControl() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
OverlayHost(uiState = samplePlayerState())
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerPlayPauseButtonTag)
|
||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
||||
.performKeyInput {
|
||||
pressKey(Key.DirectionDown)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerPlaylistCurrentItemTag)
|
||||
.assertIsFocused()
|
||||
.performKeyInput {
|
||||
pressKey(Key.DirectionUp)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerPlaylistStateTag)
|
||||
.assert(
|
||||
SemanticsMatcher.expectValue(
|
||||
SemanticsProperties.StateDescription,
|
||||
"collapsed"
|
||||
)
|
||||
)
|
||||
composeRule.onNodeWithTag(TvPlayerPlayPauseButtonTag).assertIsFocused()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rightOnLastPlaylistItem_keepsFocusInPlaylist() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
OverlayHost(uiState = samplePlayerState())
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerPlayPauseButtonTag)
|
||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
||||
.performKeyInput {
|
||||
pressKey(Key.DirectionDown)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerPlaylistCurrentItemTag)
|
||||
.assertIsFocused()
|
||||
.performKeyInput {
|
||||
pressKey(Key.DirectionRight)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerPlaylistLastItemTag)
|
||||
.assertIsFocused()
|
||||
.performKeyInput {
|
||||
pressKey(Key.DirectionRight)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerPlaylistStateTag)
|
||||
.assert(
|
||||
SemanticsMatcher.expectValue(
|
||||
SemanticsProperties.StateDescription,
|
||||
"expanded"
|
||||
)
|
||||
)
|
||||
composeRule.onNodeWithTag(TvPlayerPlaylistLastItemTag).assertIsFocused()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun playlistShowsQueueAsRowAndCurrentItemCanReceiveFocus() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
OverlayHost(
|
||||
uiState = samplePlayerState(),
|
||||
initiallyExpanded = true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerPlaylistRowTag).assertIsDisplayed()
|
||||
composeRule.onNodeWithTag(TvPlayerPlaylistCurrentItemTag)
|
||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
||||
.assertIsFocused()
|
||||
composeRule.onNodeWithText("Currently Playing").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Episode 2").assertIsDisplayed()
|
||||
composeRule.onNodeWithTag(TvPlayerPlaylistFirstItemTag).assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Current").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Already Played").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun playlistFallsBackToFirstItemWhenNoCurrentItemExists_andFallbackItemCanReceiveFocus() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
OverlayHost(
|
||||
uiState = samplePlayerState().copy(
|
||||
queue = listOf(
|
||||
PlaylistElementUiModel(
|
||||
id = "first",
|
||||
title = "Episode 1",
|
||||
subtitle = "Fallback entry",
|
||||
artworkUrl = null,
|
||||
isCurrent = false
|
||||
),
|
||||
PlaylistElementUiModel(
|
||||
id = "second",
|
||||
title = "Episode 2",
|
||||
subtitle = "Later",
|
||||
artworkUrl = null,
|
||||
isCurrent = false
|
||||
)
|
||||
)
|
||||
),
|
||||
initiallyExpanded = true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerPlaylistCurrentItemTag)
|
||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
||||
.assertIsFocused()
|
||||
composeRule.onNodeWithText("Episode 1").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun openingTrackPanel_focusesSelectedTrackAndHasNoCloseButton() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
TrackPanelHost(uiState = samplePlayerState())
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerAudioButtonTag)
|
||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
||||
.assertIsFocused()
|
||||
.performKeyInput {
|
||||
pressKey(Key.DirectionCenter)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerTrackPanelTag).assertIsDisplayed()
|
||||
composeRule.onNodeWithTag(TvPlayerTrackSelectedItemTag).assertIsFocused()
|
||||
composeRule.onAllNodesWithContentDescription("Close").assertCountEquals(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun openingTrackPanelWithoutSelection_focusesFirstTrack() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
TrackPanelHost(
|
||||
uiState = samplePlayerState().copy(selectedAudioTrackId = null)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerAudioButtonTag)
|
||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
||||
.performKeyInput {
|
||||
pressKey(Key.DirectionCenter)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerTrackFirstItemTag).assertIsFocused()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun backOnVisibleControls_hidesOverlayImmediately() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
TrackPanelHost(uiState = samplePlayerState())
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerPlayPauseButtonTag)
|
||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
||||
.assertIsFocused()
|
||||
.performKeyInput {
|
||||
pressKey(Key.Back)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onAllNodesWithTag(TvPlayerPlayPauseButtonTag).assertCountEquals(0)
|
||||
composeRule.onAllNodesWithTag(TvPlayerTrackPanelTag).assertCountEquals(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun escapeOnVisibleControls_hidesOverlayImmediately() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
TrackPanelHost(uiState = samplePlayerState())
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerPlayPauseButtonTag)
|
||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
||||
.assertIsFocused()
|
||||
.performKeyInput {
|
||||
pressKey(Key.Escape)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onAllNodesWithTag(TvPlayerPlayPauseButtonTag).assertCountEquals(0)
|
||||
composeRule.onAllNodesWithTag(TvPlayerTrackPanelTag).assertCountEquals(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hiddenControls_centerWhilePlaying_pausesWithoutShowingControlsAndShowsStopFeedback() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
TrackPanelHost(
|
||||
uiState = samplePlayerState(),
|
||||
initialControlsVisible = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerRootTag)
|
||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
||||
.performKeyInput {
|
||||
pressKey(Key.DirectionCenter)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onAllNodesWithTag(TvPlayerPlayPauseButtonTag).assertCountEquals(0)
|
||||
composeRule.onNodeWithTag(TvPlayerHiddenStopFeedbackTag).assertIsDisplayed()
|
||||
composeRule.onNodeWithTag(TvPlayerHiddenPauseCountTag)
|
||||
.assert(
|
||||
SemanticsMatcher.expectValue(
|
||||
SemanticsProperties.StateDescription,
|
||||
"1"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hiddenControls_enterWhilePlaying_pausesWithoutShowingControlsAndShowsStopFeedback() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
TrackPanelHost(
|
||||
uiState = samplePlayerState(),
|
||||
initialControlsVisible = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerRootTag)
|
||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
||||
.performKeyInput {
|
||||
pressKey(Key.Enter)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onAllNodesWithTag(TvPlayerPlayPauseButtonTag).assertCountEquals(0)
|
||||
composeRule.onNodeWithTag(TvPlayerHiddenStopFeedbackTag).assertIsDisplayed()
|
||||
composeRule.onNodeWithTag(TvPlayerHiddenPauseCountTag)
|
||||
.assert(
|
||||
SemanticsMatcher.expectValue(
|
||||
SemanticsProperties.StateDescription,
|
||||
"1"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hiddenControls_stopFeedbackAutoHidesAfterTimeout() {
|
||||
composeRule.mainClock.autoAdvance = false
|
||||
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
TrackPanelHost(
|
||||
uiState = samplePlayerState(),
|
||||
initialControlsVisible = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerRootTag)
|
||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
||||
.performKeyInput {
|
||||
pressKey(Key.DirectionCenter)
|
||||
}
|
||||
|
||||
composeRule.mainClock.advanceTimeByFrame()
|
||||
composeRule.onNodeWithTag(TvPlayerHiddenStopFeedbackTag).assertIsDisplayed()
|
||||
|
||||
composeRule.mainClock.advanceTimeBy(TV_HIDDEN_STOP_FEEDBACK_MS - 1)
|
||||
composeRule.mainClock.advanceTimeByFrame()
|
||||
composeRule.onNodeWithTag(TvPlayerHiddenStopFeedbackTag).assertIsDisplayed()
|
||||
|
||||
composeRule.mainClock.advanceTimeBy(1)
|
||||
composeRule.mainClock.advanceTimeBy(300)
|
||||
composeRule.mainClock.advanceTimeByFrame()
|
||||
composeRule.onAllNodesWithTag(TvPlayerHiddenStopFeedbackTag).assertCountEquals(0)
|
||||
|
||||
composeRule.mainClock.autoAdvance = true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hiddenControls_centerWhilePaused_resumesWithoutShowingControls() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
TrackPanelHost(
|
||||
uiState = samplePlayerState().copy(isPlaying = false),
|
||||
initialControlsVisible = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerRootTag)
|
||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
||||
.performKeyInput {
|
||||
pressKey(Key.DirectionCenter)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onAllNodesWithTag(TvPlayerPlayPauseButtonTag).assertCountEquals(0)
|
||||
composeRule.onAllNodesWithTag(TvPlayerHiddenStopFeedbackTag).assertCountEquals(0)
|
||||
composeRule.onNodeWithTag(TvPlayerHiddenPauseCountTag)
|
||||
.assert(
|
||||
SemanticsMatcher.expectValue(
|
||||
SemanticsProperties.StateDescription,
|
||||
"0"
|
||||
)
|
||||
)
|
||||
composeRule.onNodeWithTag(TvPlayerHiddenResumeCountTag)
|
||||
.assert(
|
||||
SemanticsMatcher.expectValue(
|
||||
SemanticsProperties.StateDescription,
|
||||
"1"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hiddenControls_leftSeeksWithoutShowingControls() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
TrackPanelHost(
|
||||
uiState = samplePlayerState(),
|
||||
initialControlsVisible = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerRootTag)
|
||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
||||
.performKeyInput {
|
||||
pressKey(Key.DirectionLeft)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onAllNodesWithTag(TvPlayerPlayPauseButtonTag).assertCountEquals(0)
|
||||
composeRule.onAllNodesWithTag(TvPlayerHiddenStopFeedbackTag).assertCountEquals(0)
|
||||
composeRule.onNodeWithTag(TvPlayerHiddenSeekDeltaTag)
|
||||
.assert(
|
||||
SemanticsMatcher.expectValue(
|
||||
SemanticsProperties.StateDescription,
|
||||
"-10000"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hiddenControls_rightSeeksWithoutShowingControls() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
TrackPanelHost(
|
||||
uiState = samplePlayerState(),
|
||||
initialControlsVisible = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerRootTag)
|
||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
||||
.performKeyInput {
|
||||
pressKey(Key.DirectionRight)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onAllNodesWithTag(TvPlayerPlayPauseButtonTag).assertCountEquals(0)
|
||||
composeRule.onAllNodesWithTag(TvPlayerHiddenStopFeedbackTag).assertCountEquals(0)
|
||||
composeRule.onNodeWithTag(TvPlayerHiddenSeekDeltaTag)
|
||||
.assert(
|
||||
SemanticsMatcher.expectValue(
|
||||
SemanticsProperties.StateDescription,
|
||||
"10000"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun backOnTrackPanel_closesItAndRestoresFocusToOpeningButton() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
TrackPanelHost(uiState = samplePlayerState())
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerAudioButtonTag)
|
||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
||||
.performKeyInput {
|
||||
pressKey(Key.DirectionCenter)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerTrackSelectedItemTag)
|
||||
.assertIsFocused()
|
||||
.performKeyInput {
|
||||
pressKey(Key.Back)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onAllNodesWithTag(TvPlayerTrackPanelTag).assertCountEquals(0)
|
||||
composeRule.onNodeWithTag(TvPlayerAudioButtonTag).assertIsFocused()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun leftOnTrackPanel_keepsFocusInsidePanel() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
TrackPanelHost(uiState = samplePlayerState())
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerAudioButtonTag)
|
||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
||||
.performKeyInput {
|
||||
pressKey(Key.DirectionCenter)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerTrackSelectedItemTag)
|
||||
.assertIsFocused()
|
||||
.performKeyInput {
|
||||
pressKey(Key.DirectionLeft)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerTrackPanelTag).assertIsDisplayed()
|
||||
composeRule.onNodeWithTag(TvPlayerTrackSelectedItemTag).assertIsFocused()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun selectingTrack_closesPanelAndRestoresFocusToOpeningButton() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
TrackPanelHost(uiState = samplePlayerState())
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerQualityButtonTag)
|
||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
||||
.performKeyInput {
|
||||
pressKey(Key.DirectionCenter)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerTrackSelectedItemTag)
|
||||
.assertIsFocused()
|
||||
.performKeyInput {
|
||||
pressKey(Key.DirectionCenter)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onAllNodesWithTag(TvPlayerTrackPanelTag).assertCountEquals(0)
|
||||
composeRule.onNodeWithTag(TvPlayerQualityButtonTag).assertIsFocused()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun trackButtonsWithoutOptions_areDisabled() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
TrackPanelHost(
|
||||
uiState = samplePlayerState().copy(
|
||||
audioTracks = emptyList(),
|
||||
textTracks = emptyList(),
|
||||
qualityTracks = emptyList(),
|
||||
selectedAudioTrackId = null,
|
||||
selectedTextTrackId = null,
|
||||
selectedQualityTrackId = null
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerAudioButtonTag).assertIsNotEnabled()
|
||||
composeRule.onNodeWithTag(TvPlayerSubtitlesButtonTag).assertIsNotEnabled()
|
||||
composeRule.onNodeWithTag(TvPlayerQualityButtonTag).assertIsNotEnabled()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OverlayHost(
|
||||
uiState: PlayerUiState,
|
||||
initiallyExpanded: Boolean = false,
|
||||
requestFocus: Boolean = false
|
||||
) {
|
||||
var isPlaylistExpanded by remember { mutableStateOf(initiallyExpanded) }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val qualityButtonFocusRequester = remember { FocusRequester() }
|
||||
val audioButtonFocusRequester = remember { FocusRequester() }
|
||||
val subtitlesButtonFocusRequester = remember { FocusRequester() }
|
||||
|
||||
LaunchedEffect(requestFocus) {
|
||||
if (requestFocus) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.size(width = 960.dp, height = 540.dp)) {
|
||||
TvPlayerControlsOverlay(
|
||||
uiState = uiState,
|
||||
focusRequester = focusRequester,
|
||||
isPlaylistExpanded = isPlaylistExpanded,
|
||||
qualityButtonFocusRequester = qualityButtonFocusRequester,
|
||||
audioButtonFocusRequester = audioButtonFocusRequester,
|
||||
subtitlesButtonFocusRequester = subtitlesButtonFocusRequester,
|
||||
onPlayPause = {},
|
||||
onSeek = { _ -> },
|
||||
onSeekRelative = { _ -> },
|
||||
onSeekLiveEdge = {},
|
||||
onSkipSegment = {},
|
||||
onNext = {},
|
||||
onPrevious = {},
|
||||
onOpenAudioPanel = {},
|
||||
onOpenSubtitlesPanel = {},
|
||||
onOpenQualityPanel = {},
|
||||
onExpandPlaylist = { isPlaylistExpanded = true },
|
||||
onCollapsePlaylist = { isPlaylistExpanded = false },
|
||||
onSelectQueueItem = { _ -> },
|
||||
qualityButtonEnabled = uiState.qualityTracks.isNotEmpty(),
|
||||
audioButtonEnabled = uiState.audioTracks.isNotEmpty(),
|
||||
subtitlesButtonEnabled = uiState.textTracks.isNotEmpty()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TrackPanelHost(
|
||||
uiState: PlayerUiState,
|
||||
initialControlsVisible: Boolean = true
|
||||
) {
|
||||
var controlsVisible by remember { mutableStateOf(initialControlsVisible) }
|
||||
var trackPanelType by remember { mutableStateOf<TvTrackPanelType?>(null) }
|
||||
var pendingTrackButtonFocus by remember { mutableStateOf<TvTrackPanelType?>(null) }
|
||||
var stopFeedbackVisible by remember { mutableStateOf(false) }
|
||||
var stopFeedbackRequestId by remember { mutableStateOf(0) }
|
||||
var pauseWithoutControlsCount by remember { mutableStateOf(0) }
|
||||
var resumeWithoutControlsCount by remember { mutableStateOf(0) }
|
||||
var lastHiddenSeekDeltaMs by remember { mutableStateOf(0L) }
|
||||
val rootFocusRequester = remember { FocusRequester() }
|
||||
val controlsFocusRequester = remember { FocusRequester() }
|
||||
val qualityButtonFocusRequester = remember { FocusRequester() }
|
||||
val audioButtonFocusRequester = remember { FocusRequester() }
|
||||
val subtitlesButtonFocusRequester = remember { FocusRequester() }
|
||||
val closeTrackPanel: () -> Unit = {
|
||||
trackPanelType?.let { panelType ->
|
||||
pendingTrackButtonFocus = panelType
|
||||
trackPanelType = null
|
||||
controlsVisible = true
|
||||
}
|
||||
}
|
||||
val pausePlaybackWithoutShowingControls: () -> Unit = {
|
||||
pauseWithoutControlsCount += 1
|
||||
stopFeedbackVisible = true
|
||||
stopFeedbackRequestId += 1
|
||||
}
|
||||
val resumePlaybackWithoutShowingControls: () -> Unit = {
|
||||
resumeWithoutControlsCount += 1
|
||||
stopFeedbackVisible = false
|
||||
}
|
||||
val seekWithoutShowingControls: (Long) -> Unit = { deltaMs ->
|
||||
lastHiddenSeekDeltaMs = deltaMs
|
||||
stopFeedbackVisible = false
|
||||
}
|
||||
|
||||
LaunchedEffect(trackPanelType, pendingTrackButtonFocus) {
|
||||
val pendingFocus = pendingTrackButtonFocus ?: return@LaunchedEffect
|
||||
if (trackPanelType != null) return@LaunchedEffect
|
||||
when (pendingFocus) {
|
||||
TvTrackPanelType.AUDIO -> audioButtonFocusRequester.requestFocus()
|
||||
TvTrackPanelType.SUBTITLES -> subtitlesButtonFocusRequester.requestFocus()
|
||||
TvTrackPanelType.QUALITY -> qualityButtonFocusRequester.requestFocus()
|
||||
}
|
||||
pendingTrackButtonFocus = null
|
||||
}
|
||||
|
||||
LaunchedEffect(stopFeedbackRequestId) {
|
||||
if (stopFeedbackRequestId == 0) return@LaunchedEffect
|
||||
delay(TV_HIDDEN_STOP_FEEDBACK_MS)
|
||||
stopFeedbackVisible = false
|
||||
}
|
||||
|
||||
LaunchedEffect(controlsVisible, trackPanelType) {
|
||||
if (controlsVisible || trackPanelType != null) {
|
||||
stopFeedbackVisible = false
|
||||
} else {
|
||||
rootFocusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(width = 960.dp, height = 540.dp)
|
||||
.focusRequester(rootFocusRequester)
|
||||
.focusable()
|
||||
.testTag(TvPlayerRootTag)
|
||||
.onPreviewKeyEvent { event ->
|
||||
handleTvPlayerRootKeyEvent(
|
||||
event = event,
|
||||
isPlaying = uiState.isPlaying,
|
||||
controlsVisible = controlsVisible,
|
||||
isPlaylistExpanded = false,
|
||||
trackPanelType = trackPanelType,
|
||||
onCloseTrackPanel = closeTrackPanel,
|
||||
onCollapsePlaylist = { controlsVisible = true },
|
||||
onHideControls = { controlsVisible = false },
|
||||
onPausePlaybackWithoutShowingControls = pausePlaybackWithoutShowingControls,
|
||||
onResumePlaybackWithoutShowingControls = resumePlaybackWithoutShowingControls,
|
||||
onSeekRelative = seekWithoutShowingControls,
|
||||
onShowControls = { controlsVisible = true },
|
||||
onTogglePlayPause = { controlsVisible = true }
|
||||
)
|
||||
}
|
||||
) {
|
||||
if (controlsVisible || trackPanelType != null) {
|
||||
TvPlayerControlsOverlay(
|
||||
uiState = uiState,
|
||||
focusRequester = controlsFocusRequester,
|
||||
isPlaylistExpanded = false,
|
||||
qualityButtonFocusRequester = qualityButtonFocusRequester,
|
||||
audioButtonFocusRequester = audioButtonFocusRequester,
|
||||
subtitlesButtonFocusRequester = subtitlesButtonFocusRequester,
|
||||
onPlayPause = {},
|
||||
onSeek = { _ -> },
|
||||
onSeekRelative = { _ -> },
|
||||
onSeekLiveEdge = {},
|
||||
onSkipSegment = {},
|
||||
onNext = {},
|
||||
onPrevious = {},
|
||||
onOpenAudioPanel = {
|
||||
controlsVisible = true
|
||||
trackPanelType = TvTrackPanelType.AUDIO
|
||||
},
|
||||
onOpenSubtitlesPanel = {
|
||||
controlsVisible = true
|
||||
trackPanelType = TvTrackPanelType.SUBTITLES
|
||||
},
|
||||
onOpenQualityPanel = {
|
||||
controlsVisible = true
|
||||
trackPanelType = TvTrackPanelType.QUALITY
|
||||
},
|
||||
onExpandPlaylist = {},
|
||||
onCollapsePlaylist = {},
|
||||
onSelectQueueItem = { _ -> },
|
||||
qualityButtonEnabled = uiState.qualityTracks.isNotEmpty(),
|
||||
audioButtonEnabled = uiState.audioTracks.isNotEmpty(),
|
||||
subtitlesButtonEnabled = uiState.textTracks.isNotEmpty()
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = stopFeedbackVisible,
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
) {
|
||||
TvPlayerHiddenStopFeedback()
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.testTag(TvPlayerHiddenPauseCountTag)
|
||||
.semantics { stateDescription = pauseWithoutControlsCount.toString() }
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.testTag(TvPlayerHiddenResumeCountTag)
|
||||
.semantics { stateDescription = resumeWithoutControlsCount.toString() }
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.testTag(TvPlayerHiddenSeekDeltaTag)
|
||||
.semantics { stateDescription = lastHiddenSeekDeltaMs.toString() }
|
||||
)
|
||||
|
||||
trackPanelType?.let { panelType ->
|
||||
TvTrackSelectionPanel(
|
||||
panelType = panelType,
|
||||
uiState = uiState,
|
||||
onSelect = { closeTrackPanel() },
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun samplePlayerState(): PlayerUiState = PlayerUiState(
|
||||
isPlaying = true,
|
||||
title = "Sample Player",
|
||||
subtitle = "Season 1",
|
||||
durationMs = 3_600_000,
|
||||
positionMs = 120_000,
|
||||
bufferedMs = 240_000,
|
||||
queue = listOf(
|
||||
PlaylistElementUiModel(
|
||||
id = "played",
|
||||
title = "Already Played",
|
||||
subtitle = "Episode 0",
|
||||
artworkUrl = null,
|
||||
isCurrent = false
|
||||
),
|
||||
PlaylistElementUiModel(
|
||||
id = "current",
|
||||
title = "Currently Playing",
|
||||
subtitle = "Episode 1",
|
||||
artworkUrl = null,
|
||||
isCurrent = true
|
||||
),
|
||||
PlaylistElementUiModel(
|
||||
id = "next",
|
||||
title = "Episode 2",
|
||||
subtitle = "Up next",
|
||||
artworkUrl = null,
|
||||
isCurrent = false
|
||||
)
|
||||
),
|
||||
audioTracks = listOf(
|
||||
TrackOption(
|
||||
id = "audio_en",
|
||||
label = "English 5.1",
|
||||
language = "en",
|
||||
bitrate = null,
|
||||
channelCount = 6,
|
||||
height = null,
|
||||
groupIndex = 0,
|
||||
trackIndex = 0,
|
||||
type = TrackType.AUDIO,
|
||||
isOff = false
|
||||
),
|
||||
TrackOption(
|
||||
id = "audio_hu",
|
||||
label = "Hungarian Stereo",
|
||||
language = "hu",
|
||||
bitrate = null,
|
||||
channelCount = 2,
|
||||
height = null,
|
||||
groupIndex = 0,
|
||||
trackIndex = 1,
|
||||
type = TrackType.AUDIO,
|
||||
isOff = false
|
||||
)
|
||||
),
|
||||
textTracks = listOf(
|
||||
TrackOption(
|
||||
id = "text_off",
|
||||
label = "Off",
|
||||
language = null,
|
||||
bitrate = null,
|
||||
channelCount = null,
|
||||
height = null,
|
||||
groupIndex = 1,
|
||||
trackIndex = -1,
|
||||
type = TrackType.TEXT,
|
||||
isOff = true
|
||||
),
|
||||
TrackOption(
|
||||
id = "text_en",
|
||||
label = "English CC",
|
||||
language = "en",
|
||||
bitrate = null,
|
||||
channelCount = null,
|
||||
height = null,
|
||||
groupIndex = 1,
|
||||
trackIndex = 0,
|
||||
type = TrackType.TEXT,
|
||||
isOff = false
|
||||
)
|
||||
),
|
||||
qualityTracks = listOf(
|
||||
TrackOption(
|
||||
id = "quality_auto",
|
||||
label = "Auto",
|
||||
language = null,
|
||||
bitrate = null,
|
||||
channelCount = null,
|
||||
height = 1080,
|
||||
groupIndex = 2,
|
||||
trackIndex = 0,
|
||||
type = TrackType.VIDEO,
|
||||
isOff = false
|
||||
),
|
||||
TrackOption(
|
||||
id = "quality_720",
|
||||
label = "720p",
|
||||
language = null,
|
||||
bitrate = null,
|
||||
channelCount = null,
|
||||
height = 720,
|
||||
groupIndex = 2,
|
||||
trackIndex = 1,
|
||||
type = TrackType.VIDEO,
|
||||
isOff = false
|
||||
)
|
||||
),
|
||||
selectedAudioTrackId = "audio_hu",
|
||||
selectedTextTrackId = "text_en",
|
||||
selectedQualityTrackId = "quality_auto"
|
||||
)
|
||||
@@ -29,7 +29,7 @@
|
||||
android:theme="@style/Theme.Purefin">
|
||||
<meta-data
|
||||
android:name="hu.bbara.purefin.UPDATE_MANIFEST_URL"
|
||||
android:value="http://purefin.t.bbara.hu/app-tv/update.json" />
|
||||
android:value="https://apks.t.bbara.hu/apps/purefin-app-tv/update.json" />
|
||||
<activity
|
||||
android:name="hu.bbara.purefin.tv.TvActivity"
|
||||
android:exported="true"
|
||||
|
||||
@@ -3,6 +3,7 @@ package hu.bbara.purefin.tv
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
@@ -50,6 +51,7 @@ import hu.bbara.purefin.core.jellyfin.JellyfinAuthInterceptor
|
||||
import hu.bbara.purefin.core.navigation.NavigationCommand
|
||||
import hu.bbara.purefin.core.navigation.NavigationManager
|
||||
import hu.bbara.purefin.core.navigation.Route
|
||||
import hu.bbara.purefin.ui.common.dialog.ExitAppDialog
|
||||
import hu.bbara.purefin.navigation.LocalNavigationBackStack
|
||||
import hu.bbara.purefin.navigation.LocalNavigationManager
|
||||
import hu.bbara.purefin.ui.screen.login.TvLoginScreen
|
||||
@@ -147,6 +149,7 @@ class TvActivity : ComponentActivity() {
|
||||
updateViewModel: AppUpdateViewModel = hiltViewModel()
|
||||
) {
|
||||
var sessionLoaded by remember { mutableStateOf(false) }
|
||||
var showExitDialog by remember { mutableStateOf(false) }
|
||||
val isLoggedIn by userSessionRepository.isLoggedIn.collectAsStateWithLifecycle(initialValue = false)
|
||||
val availableUpdate by updateViewModel.availableUpdate.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
@@ -168,6 +171,9 @@ class TvActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
if (!sessionLoaded) {
|
||||
BackHandler {
|
||||
showExitDialog = true
|
||||
}
|
||||
PurefinWaitingScreen(modifier = Modifier.fillMaxSize())
|
||||
} else if (isLoggedIn) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
@@ -179,7 +185,13 @@ class TvActivity : ComponentActivity() {
|
||||
LaunchedEffect(navigationManager, backStack) {
|
||||
navigationManager.commands.collect { command ->
|
||||
when (command) {
|
||||
NavigationCommand.Pop -> if (backStack.size > 1) backStack.removeLastOrNull()
|
||||
NavigationCommand.Pop -> {
|
||||
if (backStack.size > 1) {
|
||||
backStack.removeLastOrNull()
|
||||
} else {
|
||||
showExitDialog = true
|
||||
}
|
||||
}
|
||||
is NavigationCommand.Navigate -> backStack.add(command.route)
|
||||
is NavigationCommand.ReplaceAll -> {
|
||||
backStack.clear()
|
||||
@@ -189,13 +201,23 @@ class TvActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler(enabled = backStack.size == 1) {
|
||||
showExitDialog = true
|
||||
}
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalNavigationManager provides navigationManager,
|
||||
LocalNavigationBackStack provides backStack.toList()
|
||||
) {
|
||||
NavDisplay(
|
||||
backStack = backStack,
|
||||
onBack = { navigationManager.pop() },
|
||||
onBack = {
|
||||
if (backStack.size > 1) {
|
||||
navigationManager.pop()
|
||||
} else {
|
||||
showExitDialog = true
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize().background(backgroundDark),
|
||||
transitionSpec = {
|
||||
fadeIn(
|
||||
@@ -229,6 +251,9 @@ class TvActivity : ComponentActivity() {
|
||||
)
|
||||
}
|
||||
} else {
|
||||
BackHandler {
|
||||
showExitDialog = true
|
||||
}
|
||||
TvLoginScreen()
|
||||
}
|
||||
|
||||
@@ -239,6 +264,13 @@ class TvActivity : ComponentActivity() {
|
||||
onDecline = updateViewModel::declineUpdate
|
||||
)
|
||||
}
|
||||
|
||||
if (showExitDialog) {
|
||||
ExitAppDialog(
|
||||
onCloseConfirmed = { finishAndRemoveTask() },
|
||||
onDismiss = { showExitDialog = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -89,44 +89,46 @@ internal fun TvMediaDetailBodyBox(
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = bodyHeight)
|
||||
) {
|
||||
Box(modifier = Modifier.matchParentSize()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.fillMaxWidth(MediaDetailBodyImageWidthFraction)
|
||||
.align(Alignment.TopEnd)
|
||||
) {
|
||||
PurefinAsyncImage(
|
||||
model = backgroundImageUrl,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.fillMaxWidth(MediaDetailBodyImageWidthFraction)
|
||||
.align(Alignment.TopEnd),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
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
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
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(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
|
||||
@@ -5,8 +5,8 @@ import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -81,8 +81,6 @@ internal fun MediaDetailsTopBar(
|
||||
onClick = onBack,
|
||||
modifier = backModifier.then(downModifier),
|
||||
focusedScale = 1.1f,
|
||||
focusedBorderWidth = 2.5.dp,
|
||||
focusedBorderColor = scheme.primary,
|
||||
focusedBackgroundColor = scheme.primary.copy(alpha = 0.25f)
|
||||
)
|
||||
if (shortcut != null) {
|
||||
@@ -100,8 +98,6 @@ internal fun MediaDetailsTopBar(
|
||||
onClick = onCastClick,
|
||||
modifier = downModifier,
|
||||
focusedScale = 1.1f,
|
||||
focusedBorderWidth = 2.5.dp,
|
||||
focusedBorderColor = scheme.primary,
|
||||
focusedBackgroundColor = scheme.primary.copy(alpha = 0.25f)
|
||||
)
|
||||
GhostIconButton(
|
||||
@@ -110,8 +106,6 @@ internal fun MediaDetailsTopBar(
|
||||
onClick = onMoreClick,
|
||||
modifier = downModifier,
|
||||
focusedScale = 1.1f,
|
||||
focusedBorderWidth = 2.5.dp,
|
||||
focusedBorderColor = scheme.primary,
|
||||
focusedBackgroundColor = scheme.primary.copy(alpha = 0.25f)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package hu.bbara.purefin.ui.screen
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Home
|
||||
@@ -102,6 +103,10 @@ fun TvAppScreen(
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler(enabled = backStack.size == 1) {
|
||||
navigationManager.pop()
|
||||
}
|
||||
|
||||
TvNavigationDrawer(
|
||||
destinations = destinations,
|
||||
selectedDestination = selectedDestination,
|
||||
|
||||
@@ -10,7 +10,6 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import hu.bbara.purefin.core.model.MediaUiModel
|
||||
import hu.bbara.purefin.core.model.MovieUiModel
|
||||
import hu.bbara.purefin.core.model.LibraryUiModel
|
||||
@@ -40,8 +39,7 @@ fun TvHomeScreen(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
TvFocusedItemHero(
|
||||
item = focusedMediaUiModel.value,
|
||||
height = 220.dp
|
||||
item = focusedMediaUiModel.value
|
||||
)
|
||||
TvHomeContent(
|
||||
libraries = libraries,
|
||||
|
||||
@@ -6,9 +6,10 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -20,28 +21,31 @@ import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import hu.bbara.purefin.core.model.MediaUiModel
|
||||
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
internal const val TvHomeHeroTitleTag = "tv-home-hero-title"
|
||||
internal const val TvHomeHeroProgressLabelTag = "tv-home-hero-progress-label"
|
||||
|
||||
private const val TvHomeHeroAnimationMillis = 180
|
||||
// Half the screen for the billboard, half for the content rows. Tuned so both
|
||||
// the hero block and the first focused row fit on a 540dp-tall (1080p xhdpi) TV.
|
||||
internal const val TvHomeHeroHeightFraction = 0.5f
|
||||
|
||||
@Composable
|
||||
internal fun TvFocusedItemHero(
|
||||
item: MediaUiModel,
|
||||
height: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
heightFraction: Float = TvHomeHeroHeightFraction,
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(height)
|
||||
.fillMaxHeight(heightFraction)
|
||||
.background(scheme.background)
|
||||
) {
|
||||
Crossfade(
|
||||
@@ -56,20 +60,22 @@ internal fun TvFocusedItemHero(
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
// Darken the left side so the text block stays readable over any backdrop.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.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.06f)
|
||||
0.0f to scheme.background.copy(alpha = 0.86f),
|
||||
0.34f to scheme.background.copy(alpha = 0.62f),
|
||||
0.66f to scheme.background.copy(alpha = 0.22f),
|
||||
1.0f to scheme.background.copy(alpha = 0.04f)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
// Strong bottom fade so the title and metadata are legible.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -77,7 +83,8 @@ internal fun TvFocusedItemHero(
|
||||
Brush.verticalGradient(
|
||||
colorStops = arrayOf(
|
||||
0.0f to scheme.background.copy(alpha = 0f),
|
||||
0.56f to scheme.background.copy(alpha = 0.1f),
|
||||
0.40f to scheme.background.copy(alpha = 0.10f),
|
||||
0.72f to scheme.background.copy(alpha = 0.58f),
|
||||
1.0f to scheme.background
|
||||
)
|
||||
)
|
||||
@@ -92,7 +99,7 @@ internal fun TvFocusedItemHero(
|
||||
verticalArrangement = Arrangement.Bottom,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 40.dp, vertical = 18.dp)
|
||||
.padding(horizontal = 40.dp, vertical = 16.dp)
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
@@ -101,23 +108,50 @@ internal fun TvFocusedItemHero(
|
||||
Text(
|
||||
text = hero.primaryText,
|
||||
color = scheme.onBackground,
|
||||
fontSize = 34.sp,
|
||||
fontSize = 40.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
lineHeight = 38.sp,
|
||||
maxLines = 1,
|
||||
lineHeight = 44.sp,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.testTag(TvHomeHeroTitleTag)
|
||||
)
|
||||
Text(
|
||||
text = hero.description,
|
||||
color = scheme.onSurfaceVariant,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 18.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
TvHomeHeroMetadataRow(item = hero)
|
||||
if (hero.description.isNotBlank()) {
|
||||
Text(
|
||||
text = hero.description,
|
||||
color = scheme.onSurfaceVariant,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 18.sp,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TvHomeHeroMetadataRow(item: MediaUiModel) {
|
||||
val hasSecondary = item.secondaryText.isNotBlank()
|
||||
val progress = item.progress
|
||||
val hasProgress = progress != null && progress > 0f && progress < 1f
|
||||
if (!hasSecondary && !hasProgress) return
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
if (hasSecondary) {
|
||||
TvHomeMetaChip(text = item.secondaryText)
|
||||
}
|
||||
if (hasProgress) {
|
||||
val percent = ((progress ?: 0f) * 100f).roundToInt()
|
||||
TvHomeMetaChip(
|
||||
text = "Resume \u00B7 $percent%",
|
||||
highlighted = true,
|
||||
modifier = Modifier.testTag(TvHomeHeroProgressLabelTag)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Pause
|
||||
import androidx.compose.material.icons.outlined.PlayArrow
|
||||
import androidx.compose.material.icons.outlined.SkipNext
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -30,9 +31,10 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -41,33 +43,38 @@ import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEvent
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.ui.AspectRatioFrameLayout
|
||||
import androidx.media3.ui.PlayerView
|
||||
import androidx.media3.ui.SubtitleView
|
||||
import hu.bbara.purefin.core.player.model.TimedMarker
|
||||
import hu.bbara.purefin.core.player.viewmodel.ControlsAutoHideBlocker
|
||||
import hu.bbara.purefin.core.player.viewmodel.PlayerViewModel
|
||||
import hu.bbara.purefin.model.SegmentType
|
||||
import hu.bbara.purefin.ui.common.visual.ValueChangeTimedVisibility
|
||||
import hu.bbara.purefin.ui.screen.player.components.PlayerSeekBarTrack
|
||||
import hu.bbara.purefin.ui.screen.player.components.TvIconButton
|
||||
import hu.bbara.purefin.ui.screen.player.components.TvPlayerTimeRow
|
||||
import hu.bbara.purefin.ui.screen.player.components.TvNextEpisodeOverlay
|
||||
import hu.bbara.purefin.ui.screen.player.components.TvPlayerControlsOverlay
|
||||
import hu.bbara.purefin.ui.screen.player.components.TvPlayerLoadingErrorEndCard
|
||||
import hu.bbara.purefin.ui.screen.player.components.TvPlayerTimeRow
|
||||
import hu.bbara.purefin.ui.screen.player.components.TvTrackPanelType
|
||||
import hu.bbara.purefin.ui.screen.player.components.TvTrackSelectionPanel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private const val TV_CONTROLS_AUTO_HIDE_MS = 5_000L
|
||||
private const val CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION = 0.22f
|
||||
@@ -85,30 +92,43 @@ fun TvPlayerScreen(
|
||||
viewModel.loadMedia(mediaId)
|
||||
}
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
var hideControlsJob: Job? by remember { mutableStateOf(null) }
|
||||
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val controlsVisible by viewModel.controlsVisible.collectAsStateWithLifecycle()
|
||||
var controlsVisible by remember { mutableStateOf(true) }
|
||||
var isPlaylistExpanded by remember { mutableStateOf(false) }
|
||||
var trackPanelType by remember { mutableStateOf<TvTrackPanelType?>(null) }
|
||||
var pendingTrackButtonFocus by remember { mutableStateOf<TvTrackPanelType?>(null) }
|
||||
var stopFeedbackVisible by remember { mutableStateOf(false) }
|
||||
var stopFeedbackRequestId by remember { mutableStateOf(0) }
|
||||
var hiddenSeekPreviewPositionMs by remember { mutableStateOf<Long?>(null) }
|
||||
var hiddenSeekRequestId by remember { mutableStateOf(0) }
|
||||
val controlsAutoHideBlocked = isPlaylistExpanded || trackPanelType != null
|
||||
|
||||
// This is a hack for timed visibility.
|
||||
var resumeStopFeedbackCounter by remember { mutableIntStateOf(0) }
|
||||
var hiddenSeekCounter by remember { mutableIntStateOf(0) }
|
||||
|
||||
|
||||
val context = LocalContext.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
val rootFocusRequester = remember { FocusRequester() }
|
||||
val controlsFocusRequester = remember { FocusRequester() }
|
||||
|
||||
|
||||
val backgroundFocusRequester = remember { FocusRequester() }
|
||||
|
||||
// Main section focus requesters
|
||||
var rootFocusRequester by remember { mutableStateOf(backgroundFocusRequester) }
|
||||
var controlsFocusRequester by remember { mutableStateOf(FocusRequester() ) }
|
||||
|
||||
val qualityButtonFocusRequester = remember { FocusRequester() }
|
||||
val audioButtonFocusRequester = remember { FocusRequester() }
|
||||
val subtitlesButtonFocusRequester = remember { FocusRequester() }
|
||||
val skipButtonFocusRequester = remember { FocusRequester() }
|
||||
val nextEpisodeFocusRequester = remember { FocusRequester() }
|
||||
|
||||
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
|
||||
viewModel.pausePlayback()
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
(context as? Activity)?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(uiState.isPlaying) {
|
||||
val activity = context as? Activity
|
||||
if (uiState.isPlaying) {
|
||||
@@ -117,105 +137,74 @@ fun TvPlayerScreen(
|
||||
activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
(context as? Activity)?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
viewModel.setControlsAutoHideBlocked(ControlsAutoHideBlocker.PLAYLIST, false)
|
||||
viewModel.setControlsAutoHideBlocked(ControlsAutoHideBlocker.TRACK_PANEL, false)
|
||||
|
||||
fun showControls() {
|
||||
controlsVisible = true
|
||||
}
|
||||
fun hideControls() {
|
||||
controlsVisible = false
|
||||
}
|
||||
fun hideControlsWithTimeout() {
|
||||
hideControlsJob?.cancel()
|
||||
if (controlsVisible && !isPlaylistExpanded && trackPanelType == null && !uiState.isEnded && uiState.error == null) {
|
||||
hideControlsJob = scope.launch {
|
||||
delay(TV_CONTROLS_AUTO_HIDE_MS)
|
||||
hideControls()
|
||||
}
|
||||
}
|
||||
}
|
||||
// Needed because of composition scheduling
|
||||
LaunchedEffect(controlsVisible) {
|
||||
if (controlsVisible) {
|
||||
controlsFocusRequester.requestFocus()
|
||||
} else {
|
||||
rootFocusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(isPlaylistExpanded) {
|
||||
viewModel.setControlsAutoHideBlocked(ControlsAutoHideBlocker.PLAYLIST, isPlaylistExpanded)
|
||||
fun expandPlaylist() {
|
||||
// TODO: focus management for playlist expansion
|
||||
isPlaylistExpanded = true
|
||||
}
|
||||
|
||||
LaunchedEffect(trackPanelType) {
|
||||
viewModel.setControlsAutoHideBlocked(
|
||||
ControlsAutoHideBlocker.TRACK_PANEL,
|
||||
trackPanelType != null
|
||||
)
|
||||
}
|
||||
|
||||
val expandPlaylist: () -> Unit = {
|
||||
if (!isPlaylistExpanded) {
|
||||
isPlaylistExpanded = true
|
||||
}
|
||||
}
|
||||
val collapsePlaylistToControls: () -> Unit = {
|
||||
if (isPlaylistExpanded) {
|
||||
isPlaylistExpanded = false
|
||||
viewModel.showControls(TV_CONTROLS_AUTO_HIDE_MS)
|
||||
}
|
||||
}
|
||||
val showTvControls: () -> Unit = {
|
||||
viewModel.showControls(TV_CONTROLS_AUTO_HIDE_MS)
|
||||
}
|
||||
val togglePlayPauseAndShowControls: () -> Unit = {
|
||||
viewModel.togglePlayPause(TV_CONTROLS_AUTO_HIDE_MS)
|
||||
}
|
||||
val pausePlaybackWithoutShowingControls: () -> Unit = {
|
||||
viewModel.pausePlayback()
|
||||
stopFeedbackVisible = true
|
||||
stopFeedbackRequestId += 1
|
||||
}
|
||||
val resumePlaybackWithoutShowingControls: () -> Unit = {
|
||||
viewModel.resumePlayback()
|
||||
stopFeedbackVisible = false
|
||||
}
|
||||
val seekAndShowControls: (Long) -> Unit = { positionMs ->
|
||||
viewModel.seekTo(positionMs)
|
||||
showTvControls()
|
||||
}
|
||||
val seekByAndShowControls: (Long) -> Unit = { deltaMs ->
|
||||
viewModel.seekBy(deltaMs)
|
||||
showTvControls()
|
||||
}
|
||||
val seekByWithoutShowingControls: (Long) -> Unit = { deltaMs ->
|
||||
val basePositionMs = hiddenSeekPreviewPositionMs ?: uiState.positionMs
|
||||
hiddenSeekPreviewPositionMs = (basePositionMs + deltaMs).coerceSeekPosition(uiState.durationMs)
|
||||
hiddenSeekRequestId += 1
|
||||
viewModel.seekBy(deltaMs)
|
||||
stopFeedbackVisible = false
|
||||
}
|
||||
val seekToLiveEdgeAndShowControls: () -> Unit = {
|
||||
viewModel.seekToLiveEdge()
|
||||
showTvControls()
|
||||
}
|
||||
val skipSegmentAndShowControls: () -> Unit = {
|
||||
viewModel.skipActiveSegment()
|
||||
showTvControls()
|
||||
}
|
||||
val nextAndShowControls: () -> Unit = {
|
||||
viewModel.next(TV_CONTROLS_AUTO_HIDE_MS)
|
||||
}
|
||||
val previousAndShowControls: () -> Unit = {
|
||||
viewModel.previous(TV_CONTROLS_AUTO_HIDE_MS)
|
||||
fun closePlaylist() {
|
||||
// TODO focus
|
||||
isPlaylistExpanded = false
|
||||
}
|
||||
val closeTrackPanel: () -> Unit = {
|
||||
trackPanelType?.let { panelType ->
|
||||
pendingTrackButtonFocus = panelType
|
||||
trackPanelType = null
|
||||
viewModel.showControls(TV_CONTROLS_AUTO_HIDE_MS)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(
|
||||
controlsVisible,
|
||||
controlsAutoHideBlocked,
|
||||
uiState.activeSkippableSegmentEndMs
|
||||
) {
|
||||
if (controlsAutoHideBlocked) return@LaunchedEffect
|
||||
if (controlsVisible) {
|
||||
controlsFocusRequester.requestFocus()
|
||||
} else {
|
||||
if (uiState.activeSkippableSegmentEndMs != null) {
|
||||
skipButtonFocusRequester.requestFocus()
|
||||
return@LaunchedEffect
|
||||
|
||||
val showSkipIntroButton = !controlsVisible
|
||||
&& uiState.activeSkippableSegmentEndMs != null
|
||||
&& uiState.activeSkippableSegmentType == SegmentType.INTRO
|
||||
&& !uiState.isEnded
|
||||
val showNextEpisodeOverlay = !controlsVisible
|
||||
&& uiState.nextEpisode != null
|
||||
&& uiState.durationMs > 0L
|
||||
&& ((uiState.durationMs - uiState.positionMs) <= 30_000L || uiState.activeSkippableSegmentType == SegmentType.OUTRO)
|
||||
&& !uiState.isEnded
|
||||
LaunchedEffect(showSkipIntroButton, showNextEpisodeOverlay) {
|
||||
rootFocusRequester = when {
|
||||
showSkipIntroButton -> {
|
||||
skipButtonFocusRequester
|
||||
}
|
||||
focusManager.clearFocus()
|
||||
showNextEpisodeOverlay -> {
|
||||
nextEpisodeFocusRequester
|
||||
}
|
||||
else -> {
|
||||
backgroundFocusRequester
|
||||
}
|
||||
}
|
||||
if (!controlsVisible) {
|
||||
rootFocusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
LaunchedEffect(trackPanelType, pendingTrackButtonFocus) {
|
||||
val pendingFocus = pendingTrackButtonFocus ?: return@LaunchedEffect
|
||||
if (trackPanelType != null) return@LaunchedEffect
|
||||
@@ -227,29 +216,8 @@ fun TvPlayerScreen(
|
||||
pendingTrackButtonFocus = null
|
||||
}
|
||||
|
||||
LaunchedEffect(stopFeedbackRequestId) {
|
||||
if (stopFeedbackRequestId == 0) return@LaunchedEffect
|
||||
delay(TV_HIDDEN_STOP_FEEDBACK_MS)
|
||||
stopFeedbackVisible = false
|
||||
}
|
||||
|
||||
LaunchedEffect(hiddenSeekRequestId) {
|
||||
if (hiddenSeekRequestId == 0) return@LaunchedEffect
|
||||
delay(TV_HIDDEN_STOP_FEEDBACK_MS)
|
||||
hiddenSeekPreviewPositionMs = null
|
||||
}
|
||||
|
||||
LaunchedEffect(controlsVisible, isPlaylistExpanded, trackPanelType, uiState.isEnded, uiState.error) {
|
||||
if (controlsVisible || isPlaylistExpanded || trackPanelType != null || uiState.isEnded || uiState.error != null) {
|
||||
stopFeedbackVisible = false
|
||||
hiddenSeekPreviewPositionMs = null
|
||||
}
|
||||
}
|
||||
|
||||
val playerControlsVisible =
|
||||
controlsVisible || isPlaylistExpanded || trackPanelType != null || uiState.isEnded || uiState.error != null
|
||||
val subtitleBottomPaddingFraction =
|
||||
if (playerControlsVisible) {
|
||||
if (controlsVisible) {
|
||||
CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION
|
||||
} else {
|
||||
SubtitleView.DEFAULT_BOTTOM_PADDING_FRACTION
|
||||
@@ -258,8 +226,12 @@ fun TvPlayerScreen(
|
||||
BackHandler(enabled = true) {
|
||||
when {
|
||||
trackPanelType != null -> closeTrackPanel()
|
||||
isPlaylistExpanded -> collapsePlaylistToControls()
|
||||
controlsVisible -> viewModel.toggleControlsVisibility()
|
||||
isPlaylistExpanded -> {
|
||||
closePlaylist()
|
||||
}
|
||||
controlsVisible -> {
|
||||
hideControls()
|
||||
}
|
||||
else -> onBack()
|
||||
}
|
||||
}
|
||||
@@ -270,23 +242,32 @@ fun TvPlayerScreen(
|
||||
.background(Color.Black)
|
||||
.focusRequester(rootFocusRequester)
|
||||
.onPreviewKeyEvent { event ->
|
||||
handleTvPlayerRootKeyEvent(
|
||||
val handled = handleTvPlayerRootKeyEvent(
|
||||
event = event,
|
||||
isPlaying = uiState.isPlaying,
|
||||
controlsVisible = controlsVisible,
|
||||
popupVisible = showSkipIntroButton || showNextEpisodeOverlay,
|
||||
onShowControls = ::showControls,
|
||||
isPlaylistExpanded = isPlaylistExpanded,
|
||||
trackPanelType = trackPanelType,
|
||||
onCloseTrackPanel = closeTrackPanel,
|
||||
onCollapsePlaylist = collapsePlaylistToControls,
|
||||
onHideControls = { viewModel.toggleControlsVisibility() },
|
||||
onPausePlaybackWithoutShowingControls = pausePlaybackWithoutShowingControls,
|
||||
onResumePlaybackWithoutShowingControls = resumePlaybackWithoutShowingControls,
|
||||
onSeekRelative = seekByWithoutShowingControls,
|
||||
onShowControls = showTvControls,
|
||||
onSkipSegment = skipSegmentAndShowControls,
|
||||
hasSkippableSegment = uiState.activeSkippableSegmentEndMs != null,
|
||||
onTogglePlayPause = togglePlayPauseAndShowControls
|
||||
onCollapsePlaylist = {},
|
||||
onHideControls = ::hideControls,
|
||||
onTogglePlayback = {
|
||||
// This is a hack to trigger the ValueChangeTimedVisibility to show the hidden resume/stop feedback.
|
||||
resumeStopFeedbackCounter++
|
||||
viewModel.togglePlayPause()
|
||||
},
|
||||
onSeekRelative = {
|
||||
// This is a hack to trigger the ValueChangeTimedVisibility to show the hidden seek timeline.
|
||||
hiddenSeekCounter++
|
||||
viewModel.seekBy(it)
|
||||
},
|
||||
)
|
||||
if (event.type == KeyEventType.KeyDown) {
|
||||
hideControlsWithTimeout()
|
||||
}
|
||||
handled
|
||||
}
|
||||
.focusable()
|
||||
) {
|
||||
@@ -309,7 +290,7 @@ fun TvPlayerScreen(
|
||||
)
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = playerControlsVisible,
|
||||
visible = controlsVisible,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
) {
|
||||
@@ -321,21 +302,28 @@ fun TvPlayerScreen(
|
||||
qualityButtonFocusRequester = qualityButtonFocusRequester,
|
||||
audioButtonFocusRequester = audioButtonFocusRequester,
|
||||
subtitlesButtonFocusRequester = subtitlesButtonFocusRequester,
|
||||
onPlayPause = togglePlayPauseAndShowControls,
|
||||
onSeek = seekAndShowControls,
|
||||
onSeekRelative = seekByAndShowControls,
|
||||
onSeekLiveEdge = seekToLiveEdgeAndShowControls,
|
||||
onSkipSegment = skipSegmentAndShowControls,
|
||||
onNext = nextAndShowControls,
|
||||
onPrevious = previousAndShowControls,
|
||||
onPlayPause = { viewModel.togglePlayPause() },
|
||||
onSeek = { positionMs ->
|
||||
viewModel.seekTo(positionMs)
|
||||
},
|
||||
onSeekRelative = { deltaMs ->
|
||||
viewModel.seekBy(deltaMs)
|
||||
},
|
||||
onSeekLiveEdge = {
|
||||
viewModel.seekToLiveEdge()
|
||||
},
|
||||
onSkipSegment = {
|
||||
viewModel.skipActiveSegment()
|
||||
},
|
||||
onNext = { viewModel.next() },
|
||||
onPrevious = { viewModel.previous() },
|
||||
onOpenAudioPanel = { trackPanelType = TvTrackPanelType.AUDIO },
|
||||
onOpenSubtitlesPanel = { trackPanelType = TvTrackPanelType.SUBTITLES },
|
||||
onOpenQualityPanel = { trackPanelType = TvTrackPanelType.QUALITY },
|
||||
onExpandPlaylist = expandPlaylist,
|
||||
onCollapsePlaylist = collapsePlaylistToControls,
|
||||
onExpandPlaylist = ::expandPlaylist,
|
||||
onCollapsePlaylist = ::closePlaylist,
|
||||
onSelectQueueItem = { id ->
|
||||
viewModel.playQueueItem(id)
|
||||
collapsePlaylistToControls()
|
||||
},
|
||||
qualityButtonEnabled = uiState.qualityTracks.isNotEmpty(),
|
||||
audioButtonEnabled = uiState.audioTracks.isNotEmpty(),
|
||||
@@ -344,20 +332,18 @@ fun TvPlayerScreen(
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = !playerControlsVisible && uiState.activeSkippableSegmentEndMs != null,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
visible = showSkipIntroButton,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(
|
||||
end = 24.dp,
|
||||
bottom = if (hiddenSeekPreviewPositionMs != null) 104.dp else 24.dp
|
||||
bottom = 24.dp
|
||||
)
|
||||
) {
|
||||
TvIconButton(
|
||||
icon = Icons.Outlined.SkipNext,
|
||||
contentDescription = "Skip segment",
|
||||
onClick = skipSegmentAndShowControls,
|
||||
onClick = { viewModel.skipActiveSegment() },
|
||||
size = 64,
|
||||
label = "Skip",
|
||||
modifier = Modifier.focusRequester(skipButtonFocusRequester)
|
||||
@@ -365,15 +351,32 @@ fun TvPlayerScreen(
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = !playerControlsVisible && hiddenSeekPreviewPositionMs != null,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
visible = showNextEpisodeOverlay,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(
|
||||
end = 32.dp,
|
||||
bottom = 140.dp
|
||||
)
|
||||
) {
|
||||
uiState.nextEpisode?.let { nextEpisode ->
|
||||
TvNextEpisodeOverlay(
|
||||
nextEpisode = nextEpisode,
|
||||
onClick = { viewModel.next() },
|
||||
modifier = Modifier.focusRequester(nextEpisodeFocusRequester)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ValueChangeTimedVisibility(
|
||||
value = hiddenSeekCounter,
|
||||
hideAfterMillis = 2500L,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(horizontal = 32.dp, vertical = 28.dp)
|
||||
) {
|
||||
HiddenTvSeekTimeline(
|
||||
positionMs = hiddenSeekPreviewPositionMs ?: uiState.positionMs,
|
||||
positionMs = uiState.positionMs,
|
||||
durationMs = uiState.durationMs,
|
||||
bufferedMs = uiState.bufferedMs,
|
||||
chapterMarkers = uiState.chapters,
|
||||
@@ -385,21 +388,20 @@ fun TvPlayerScreen(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
uiState = uiState,
|
||||
onRetry = { viewModel.retry() },
|
||||
onNext = nextAndShowControls,
|
||||
onNext = { viewModel.next() },
|
||||
onReplay = {
|
||||
viewModel.seekTo(0L)
|
||||
togglePlayPauseAndShowControls()
|
||||
viewModel.resumePlayback()
|
||||
},
|
||||
onDismissError = { viewModel.clearError() }
|
||||
)
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = stopFeedbackVisible,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
ValueChangeTimedVisibility(
|
||||
value = resumeStopFeedbackCounter,
|
||||
hideAfterMillis = TV_HIDDEN_STOP_FEEDBACK_MS,
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
) {
|
||||
TvPlayerHiddenStopFeedback()
|
||||
TvPlayerResumeStopFeedback(resume = uiState.isPlaying)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
@@ -463,29 +465,19 @@ private fun HiddenTvSeekTimeline(
|
||||
}
|
||||
}
|
||||
|
||||
private fun Long.coerceSeekPosition(durationMs: Long): Long =
|
||||
if (durationMs > 0L) {
|
||||
coerceIn(0L, durationMs)
|
||||
} else {
|
||||
coerceAtLeast(0L)
|
||||
}
|
||||
|
||||
internal fun handleTvPlayerRootKeyEvent(
|
||||
event: androidx.compose.ui.input.key.KeyEvent,
|
||||
event: KeyEvent,
|
||||
isPlaying: Boolean,
|
||||
controlsVisible: Boolean,
|
||||
popupVisible: Boolean,
|
||||
isPlaylistExpanded: Boolean,
|
||||
trackPanelType: TvTrackPanelType?,
|
||||
onCloseTrackPanel: () -> Unit,
|
||||
onCollapsePlaylist: () -> Unit,
|
||||
onHideControls: () -> Unit,
|
||||
onPausePlaybackWithoutShowingControls: () -> Unit,
|
||||
onResumePlaybackWithoutShowingControls: () -> Unit,
|
||||
onTogglePlayback: () -> Unit,
|
||||
onSeekRelative: (Long) -> Unit,
|
||||
onShowControls: () -> Unit,
|
||||
onSkipSegment: () -> Unit = {},
|
||||
hasSkippableSegment: Boolean = false,
|
||||
onTogglePlayPause: () -> Unit
|
||||
): Boolean {
|
||||
if (event.type != KeyEventType.KeyDown) return false
|
||||
|
||||
@@ -518,7 +510,7 @@ internal fun handleTvPlayerRootKeyEvent(
|
||||
}
|
||||
|
||||
Key.DirectionRight -> {
|
||||
onSeekRelative(10_000)
|
||||
onSeekRelative(30_000)
|
||||
true
|
||||
}
|
||||
|
||||
@@ -528,14 +520,13 @@ internal fun handleTvPlayerRootKeyEvent(
|
||||
}
|
||||
|
||||
Key.DirectionCenter, Key.Enter -> {
|
||||
if (hasSkippableSegment) {
|
||||
onSkipSegment()
|
||||
} else if (isPlaying) {
|
||||
onPausePlaybackWithoutShowingControls()
|
||||
if (popupVisible) {
|
||||
// Do nothing because the focused component is not he root, but an overlay
|
||||
false
|
||||
} else {
|
||||
onResumePlaybackWithoutShowingControls()
|
||||
onTogglePlayback()
|
||||
true
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
@@ -546,7 +537,8 @@ internal fun handleTvPlayerRootKeyEvent(
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun TvPlayerHiddenStopFeedback(
|
||||
internal fun TvPlayerResumeStopFeedback(
|
||||
resume: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Box(
|
||||
@@ -558,8 +550,8 @@ internal fun TvPlayerHiddenStopFeedback(
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Pause,
|
||||
contentDescription = "Pause playback",
|
||||
imageVector = if (resume) Icons.Outlined.PlayArrow else Icons.Outlined.Pause,
|
||||
contentDescription = "Play/Pause playback",
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.size(72.dp)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package hu.bbara.purefin.ui.screen.player.components
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import hu.bbara.purefin.core.player.model.PlaylistElementUiModel
|
||||
|
||||
/**
|
||||
* TV-optimized overlay button that appears near the end of a media item to let the user
|
||||
* quickly jump to the next episode. Shows the next episode's artwork and title.
|
||||
* Supports D-pad navigation and focus animations.
|
||||
*/
|
||||
@Composable
|
||||
fun TvNextEpisodeOverlay(
|
||||
nextEpisode: PlaylistElementUiModel,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
val scale by animateFloatAsState(
|
||||
targetValue = if (isFocused) 1.05f else 1f,
|
||||
label = "nextEpisodeScale"
|
||||
)
|
||||
val borderColor by animateColorAsState(
|
||||
targetValue = if (isFocused) scheme.primary else Color.Transparent,
|
||||
label = "nextEpisodeBorder"
|
||||
)
|
||||
val backgroundColor by animateColorAsState(
|
||||
targetValue = if (isFocused) {
|
||||
scheme.surfaceContainerHigh.copy(alpha = 0.96f)
|
||||
} else {
|
||||
Color.Black.copy(alpha = 0.85f)
|
||||
},
|
||||
label = "nextEpisodeBackground"
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.width(280.dp)
|
||||
.graphicsLayer {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
}
|
||||
.border(
|
||||
width = if (isFocused) 3.dp else 0.dp,
|
||||
color = borderColor,
|
||||
shape = RoundedCornerShape(20.dp)
|
||||
)
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(backgroundColor)
|
||||
.onFocusChanged { isFocused = it.isFocused }
|
||||
.clickable { onClick() },
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp)
|
||||
) {
|
||||
// "Up Next" label
|
||||
Text(
|
||||
text = "Up Next",
|
||||
color = scheme.secondary,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
modifier = Modifier.padding(start = 16.dp, top = 12.dp, end = 16.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
|
||||
// Thumbnail with play button overlay
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.aspectRatio(16f / 9f)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
) {
|
||||
AsyncImage(
|
||||
model = nextEpisode.artworkUrl?.takeIf { it.isNotEmpty() },
|
||||
contentDescription = nextEpisode.title,
|
||||
modifier = Modifier.matchParentSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
|
||||
// Dark gradient overlay for readability
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color.Black.copy(alpha = 0.1f),
|
||||
Color.Black.copy(alpha = 0.4f)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Episode title
|
||||
Text(
|
||||
text = nextEpisode.title,
|
||||
color = scheme.onSurface,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 14.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal const val TvPlayerNextEpisodeOverlayTag = "tv_player_next_episode_overlay"
|
||||
@@ -13,11 +13,13 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text as TvText
|
||||
import hu.bbara.purefin.core.feature.content.series.SeriesViewModel
|
||||
import hu.bbara.purefin.model.Episode
|
||||
import hu.bbara.purefin.model.Season
|
||||
@@ -49,7 +51,7 @@ fun TvSeriesScreen(
|
||||
val series = viewModel.series.collectAsStateWithLifecycle()
|
||||
|
||||
val seriesData = series.value
|
||||
if (seriesData != null && seriesData.seasons.isNotEmpty()) {
|
||||
if (seriesData != null) {
|
||||
TvSeriesScreenContent(
|
||||
series = seriesData,
|
||||
onPlayEpisode = viewModel::onPlayEpisode,
|
||||
@@ -74,25 +76,27 @@ internal fun TvSeriesScreenContent(
|
||||
) {
|
||||
val defaultSeason = series.defaultSeason(focusedSeasonId)
|
||||
var selectedSeasonId by remember(series.id, focusedSeasonId) {
|
||||
mutableStateOf(defaultSeason.id)
|
||||
mutableStateOf(defaultSeason?.id)
|
||||
}
|
||||
val selectedSeason = series.seasons.firstOrNull { it.id == selectedSeasonId } ?: defaultSeason
|
||||
val initialFocusSeasonId = remember(series.id, focusedSeasonId) { defaultSeason.id }
|
||||
val initialFocusSeason = series.seasons.firstOrNull { it.id == initialFocusSeasonId } ?: defaultSeason
|
||||
val initialFocusedEpisodeId = initialFocusSeason.focusTargetEpisodeId(focusedEpisodeId)
|
||||
val initialFocusSeasonId = defaultSeason?.id
|
||||
val initialFocusSeason = initialFocusSeasonId?.let { seasonId ->
|
||||
series.seasons.firstOrNull { it.id == seasonId }
|
||||
} ?: defaultSeason
|
||||
val initialFocusedEpisodeId = initialFocusSeason?.focusTargetEpisodeId(focusedEpisodeId)
|
||||
val seasonTabFocusRequester = remember { FocusRequester() }
|
||||
var requestedInitialEpisodeFocus by remember(series.id, focusedSeasonId, focusedEpisodeId) {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
var requestedInitialSeasonFocus by remember(series.id, focusedSeasonId, focusedEpisodeId) {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
val waitingForInitialEpisodes = initialFocusedEpisodeId == null &&
|
||||
initialFocusSeason.episodes.isEmpty() &&
|
||||
initialFocusSeason?.episodes?.isEmpty() == true &&
|
||||
initialFocusSeason.episodeCount > 0
|
||||
|
||||
LaunchedEffect(series.id, selectedSeason.id) {
|
||||
onLoadSeasonEpisodes(series.id, selectedSeason.id)
|
||||
}
|
||||
|
||||
LaunchedEffect(series.id, initialFocusedEpisodeId, waitingForInitialEpisodes) {
|
||||
if (initialFocusedEpisodeId != null || waitingForInitialEpisodes) return@LaunchedEffect
|
||||
withFrameNanos { }
|
||||
seasonTabFocusRequester.requestFocus()
|
||||
LaunchedEffect(series.id, selectedSeason?.id) {
|
||||
selectedSeason?.let { onLoadSeasonEpisodes(series.id, it.id) }
|
||||
}
|
||||
|
||||
TvMediaDetailScaffold(
|
||||
@@ -114,32 +118,48 @@ internal fun TvSeriesScreenContent(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
TvSeasonTabs(
|
||||
seasons = series.seasons,
|
||||
selectedSeason = selectedSeason,
|
||||
selectedItemFocusRequester = seasonTabFocusRequester,
|
||||
firstItemTestTag = SeriesFirstSeasonTabTag,
|
||||
onSelect = { selectedSeasonId = it.id },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
TvEpisodeCarousel(
|
||||
episodes = selectedSeason.episodes,
|
||||
onPlayEpisode = { onPlayEpisode(it.id) },
|
||||
focusedEpisodeId = initialFocusedEpisodeId,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
if (selectedSeason != null) {
|
||||
TvSeasonTabs(
|
||||
seasons = series.seasons,
|
||||
selectedSeason = selectedSeason,
|
||||
selectedItemFocusRequester = seasonTabFocusRequester,
|
||||
firstItemTestTag = SeriesFirstSeasonTabTag,
|
||||
requestSelectedItemFocus = initialFocusedEpisodeId == null &&
|
||||
!waitingForInitialEpisodes &&
|
||||
!requestedInitialSeasonFocus,
|
||||
onSelectedItemFocusRequested = { requestedInitialSeasonFocus = true },
|
||||
onSelect = { selectedSeasonId = it.id },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
TvEpisodeCarousel(
|
||||
episodes = selectedSeason.episodes,
|
||||
onPlayEpisode = { onPlayEpisode(it.id) },
|
||||
focusedEpisodeId = initialFocusedEpisodeId,
|
||||
requestFocus = selectedSeason.id == initialFocusSeasonId && !requestedInitialEpisodeFocus,
|
||||
onFocusRequested = { requestedInitialEpisodeFocus = true },
|
||||
upFocusRequester = seasonTabFocusRequester,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
} else {
|
||||
TvText(
|
||||
text = "Loading seasons...",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 13.sp,
|
||||
modifier = Modifier.padding(vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Series.defaultSeason(focusedSeasonId: UUID?): Season {
|
||||
private fun Series.defaultSeason(focusedSeasonId: UUID?): Season? {
|
||||
if (focusedSeasonId != null) {
|
||||
seasons.firstOrNull { it.id == focusedSeasonId }?.let { return it }
|
||||
}
|
||||
|
||||
return seasons.firstOrNull { it.unwatchedEpisodeCount > 0 } ?: seasons.first()
|
||||
return seasons.firstOrNull { it.unwatchedEpisodeCount > 0 } ?: seasons.firstOrNull()
|
||||
}
|
||||
|
||||
private fun Season.nextUpEpisode(): Episode? {
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -18,9 +19,9 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -40,9 +41,11 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.focusRestorer
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.testTag
|
||||
@@ -50,9 +53,6 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.Tab
|
||||
import androidx.tv.material3.TabDefaults
|
||||
import androidx.tv.material3.TabRow
|
||||
import hu.bbara.purefin.core.image.ArtworkKind
|
||||
import hu.bbara.purefin.core.image.ImageUrlBuilder
|
||||
import hu.bbara.purefin.model.CastMember
|
||||
@@ -78,30 +78,51 @@ internal fun TvSeriesMetaChips(series: Series) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
internal fun TvSeasonTabs(
|
||||
seasons: List<Season>,
|
||||
selectedSeason: Season?,
|
||||
modifier: Modifier = Modifier,
|
||||
selectedItemFocusRequester: FocusRequester? = null,
|
||||
firstItemTestTag: String? = null,
|
||||
requestSelectedItemFocus: Boolean = false,
|
||||
onSelectedItemFocusRequested: () -> Unit = {},
|
||||
onSelect: (Season) -> Unit
|
||||
) {
|
||||
val selectedSeasonIndex = seasons.indexOf(selectedSeason).coerceAtLeast(0)
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
TabRow(
|
||||
selectedTabIndex = selectedSeasonIndex,
|
||||
LaunchedEffect(selectedSeasonIndex, requestSelectedItemFocus, seasons.size) {
|
||||
if (seasons.isEmpty()) return@LaunchedEffect
|
||||
listState.scrollToItem(selectedSeasonIndex)
|
||||
if (requestSelectedItemFocus && selectedItemFocusRequester != null) {
|
||||
withFrameNanos { }
|
||||
selectedItemFocusRequester.requestFocus()
|
||||
onSelectedItemFocusRequested()
|
||||
}
|
||||
}
|
||||
|
||||
LazyRow(
|
||||
state = listState,
|
||||
modifier = modifier
|
||||
.focusRestorer()
|
||||
.fillMaxWidth()
|
||||
.wrapContentWidth(Alignment.Start),
|
||||
contentColor = MaterialTheme.colorScheme.onSurface
|
||||
.then(
|
||||
if (selectedItemFocusRequester != null) {
|
||||
Modifier.focusRestorer(selectedItemFocusRequester)
|
||||
} else {
|
||||
Modifier.focusRestorer()
|
||||
}
|
||||
)
|
||||
.focusGroup()
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(0.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
seasons.forEachIndexed { index, season ->
|
||||
Tab(
|
||||
selected = index == selectedSeasonIndex,
|
||||
onFocus = { onSelect(season) },
|
||||
itemsIndexed(seasons, key = { _, season -> season.id }) { index, season ->
|
||||
TvSeasonTab(
|
||||
name = season.name,
|
||||
isSelected = index == selectedSeasonIndex,
|
||||
onFocused = { onSelect(season) },
|
||||
onClick = { onSelect(season) },
|
||||
colors = TabDefaults.underlinedIndicatorTabColors(),
|
||||
modifier = Modifier
|
||||
.then(
|
||||
if (index == selectedSeasonIndex && selectedItemFocusRequester != null) {
|
||||
@@ -117,60 +138,108 @@ internal fun TvSeasonTabs(
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
) {
|
||||
TvText(
|
||||
text = season.name,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = if (index == selectedSeasonIndex) {
|
||||
FontWeight.Bold
|
||||
} else {
|
||||
FontWeight.Medium
|
||||
},
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TvSeasonTab(
|
||||
name: String,
|
||||
isSelected: Boolean,
|
||||
onFocused: () -> Unit,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val color = if (isSelected) scheme.primary else scheme.onSurface
|
||||
val indicatorColor = if (isSelected) scheme.primary else Color.Transparent
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.onFocusChanged { state ->
|
||||
if (state.isFocused) onFocused()
|
||||
}
|
||||
.clickable(onClick = onClick)
|
||||
) {
|
||||
TvText(
|
||||
text = name,
|
||||
color = color,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(2.dp)
|
||||
.background(indicatorColor)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
internal fun TvEpisodeCarousel(
|
||||
episodes: List<Episode>,
|
||||
onPlayEpisode: (Episode) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
focusedEpisodeId: UUID? = null
|
||||
focusedEpisodeId: UUID? = null,
|
||||
requestFocus: Boolean = false,
|
||||
onFocusRequested: () -> Unit = {},
|
||||
upFocusRequester: FocusRequester? = null
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
val focusedEpisodeFocusRequester = remember { FocusRequester() }
|
||||
val rowScopedFocusRequester = remember { FocusRequester() }
|
||||
var rowFocusedEpisodeId by remember { mutableStateOf<UUID?>(null) }
|
||||
var requestedFocusEpisodeId by remember { mutableStateOf<UUID?>(null) }
|
||||
val focusedEpisodeIndex = focusedEpisodeId?.let { id ->
|
||||
episodes.indexOfFirst { it.id == id }
|
||||
} ?: -1
|
||||
val firstUnwatchedIndex = episodes.indexOfFirst { !it.watched }
|
||||
val targetEpisodeIndex = when {
|
||||
focusedEpisodeIndex >= 0 -> focusedEpisodeIndex
|
||||
firstUnwatchedIndex >= 0 -> firstUnwatchedIndex
|
||||
episodes.isNotEmpty() -> 0
|
||||
else -> -1
|
||||
}
|
||||
val targetEpisodeId = episodes.getOrNull(targetEpisodeIndex)?.id
|
||||
|
||||
LaunchedEffect(episodes, focusedEpisodeId) {
|
||||
val focusedEpisodeIndex = focusedEpisodeId?.let { id ->
|
||||
episodes.indexOfFirst { it.id == id }
|
||||
} ?: -1
|
||||
val firstUnwatchedIndex = episodes.indexOfFirst { !it.watched }.let { if (it == -1) 0 else it }
|
||||
val targetIndex = focusedEpisodeIndex.takeIf { it >= 0 } ?: firstUnwatchedIndex
|
||||
LaunchedEffect(episodes, targetEpisodeId) {
|
||||
if (episodes.none { it.id == rowFocusedEpisodeId }) {
|
||||
rowFocusedEpisodeId = targetEpisodeId
|
||||
}
|
||||
|
||||
if (targetIndex != 0) {
|
||||
listState.scrollToItem(targetIndex)
|
||||
if (targetEpisodeIndex > 0) {
|
||||
listState.scrollToItem(targetEpisodeIndex)
|
||||
} else {
|
||||
listState.scrollToItem(0)
|
||||
}
|
||||
}
|
||||
|
||||
if (focusedEpisodeIndex >= 0 && requestedFocusEpisodeId != focusedEpisodeId) {
|
||||
LaunchedEffect(requestFocus, targetEpisodeId) {
|
||||
if (requestFocus && targetEpisodeId != null && requestedFocusEpisodeId != targetEpisodeId) {
|
||||
rowFocusedEpisodeId = targetEpisodeId
|
||||
if (targetEpisodeIndex > 0) {
|
||||
listState.scrollToItem(targetEpisodeIndex)
|
||||
} else {
|
||||
listState.scrollToItem(0)
|
||||
}
|
||||
withFrameNanos { }
|
||||
focusedEpisodeFocusRequester.requestFocus()
|
||||
requestedFocusEpisodeId = focusedEpisodeId
|
||||
rowScopedFocusRequester.requestFocus()
|
||||
requestedFocusEpisodeId = targetEpisodeId
|
||||
onFocusRequested()
|
||||
}
|
||||
}
|
||||
|
||||
CompositionLocalProvider(LocalBringIntoViewSpec provides TvHomeRowBringIntoViewSpec) {
|
||||
LazyRow(
|
||||
state = listState,
|
||||
modifier = modifier,
|
||||
modifier = modifier
|
||||
.focusRestorer(rowScopedFocusRequester)
|
||||
.focusGroup(),
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
@@ -178,13 +247,27 @@ internal fun TvEpisodeCarousel(
|
||||
TvEpisodeCard(
|
||||
episode = episode,
|
||||
onPlayEpisode = { onPlayEpisode(episode) },
|
||||
modifier = if (episode.id == focusedEpisodeId) {
|
||||
Modifier
|
||||
.focusRequester(focusedEpisodeFocusRequester)
|
||||
.testTag(SeriesNextUpEpisodeCardTag)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
onFocused = { rowFocusedEpisodeId = episode.id },
|
||||
modifier = Modifier
|
||||
.then(
|
||||
if (episode.id == rowFocusedEpisodeId) {
|
||||
Modifier.focusRequester(rowScopedFocusRequester)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.then(
|
||||
upFocusRequester?.let { requester ->
|
||||
Modifier.focusProperties { up = requester }
|
||||
} ?: Modifier
|
||||
)
|
||||
.then(
|
||||
if (episode.id == targetEpisodeId) {
|
||||
Modifier.testTag(SeriesNextUpEpisodeCardTag)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -239,6 +322,7 @@ internal fun TvSeriesHeroSection(
|
||||
private fun TvEpisodeCard(
|
||||
episode: Episode,
|
||||
onPlayEpisode: () -> Unit,
|
||||
onFocused: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
@@ -250,7 +334,10 @@ private fun TvEpisodeCard(
|
||||
modifier = modifier
|
||||
.width(260.dp)
|
||||
.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||
.onFocusChanged { isFocused = it.isFocused }
|
||||
.onFocusChanged {
|
||||
isFocused = it.isFocused
|
||||
if (it.isFocused) onFocused()
|
||||
}
|
||||
.clickable { onPlayEpisode() },
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package hu.bbara.purefin
|
||||
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.os.Bundle
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
@@ -51,6 +52,7 @@ import hu.bbara.purefin.navigation.LocalSharedTransitionScope
|
||||
import hu.bbara.purefin.core.navigation.NavigationCommand
|
||||
import hu.bbara.purefin.core.navigation.NavigationManager
|
||||
import hu.bbara.purefin.core.navigation.Route
|
||||
import hu.bbara.purefin.ui.common.dialog.ExitAppDialog
|
||||
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
|
||||
import hu.bbara.purefin.ui.screen.login.LoginScreen
|
||||
import hu.bbara.purefin.ui.theme.AppTheme
|
||||
@@ -146,6 +148,7 @@ class PurefinActivity : ComponentActivity() {
|
||||
navigationManager: NavigationManager
|
||||
) {
|
||||
var sessionLoaded by remember { mutableStateOf(false) }
|
||||
var showExitDialog by remember { mutableStateOf(false) }
|
||||
val isLoggedIn by userSessionRepository.isLoggedIn.collectAsStateWithLifecycle(initialValue = false)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
@@ -155,11 +158,11 @@ class PurefinActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
if (!sessionLoaded) {
|
||||
BackHandler {
|
||||
showExitDialog = true
|
||||
}
|
||||
PurefinWaitingScreen(modifier = Modifier.fillMaxSize())
|
||||
return
|
||||
}
|
||||
|
||||
if (isLoggedIn) {
|
||||
} else if (isLoggedIn) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val backStack = rememberNavBackStack(Route.Home) as NavBackStack<Route>
|
||||
var homeMediaSharedBoundsKey by remember { mutableStateOf<String?>(null) }
|
||||
@@ -171,7 +174,13 @@ class PurefinActivity : ComponentActivity() {
|
||||
LaunchedEffect(navigationManager, backStack) {
|
||||
navigationManager.commands.collect { command ->
|
||||
when (command) {
|
||||
NavigationCommand.Pop -> if (backStack.size > 1) backStack.removeLastOrNull()
|
||||
NavigationCommand.Pop -> {
|
||||
if (backStack.size > 1) {
|
||||
backStack.removeLastOrNull()
|
||||
} else {
|
||||
showExitDialog = true
|
||||
}
|
||||
}
|
||||
is NavigationCommand.Navigate -> backStack.add(command.route)
|
||||
is NavigationCommand.ReplaceAll -> {
|
||||
backStack.clear()
|
||||
@@ -181,6 +190,10 @@ class PurefinActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler(enabled = backStack.size == 1) {
|
||||
showExitDialog = true
|
||||
}
|
||||
|
||||
SharedTransitionLayout {
|
||||
CompositionLocalProvider(
|
||||
LocalNavigationManager provides navigationManager,
|
||||
@@ -193,7 +206,13 @@ class PurefinActivity : ComponentActivity() {
|
||||
) {
|
||||
NavDisplay(
|
||||
backStack = backStack,
|
||||
onBack = { navigationManager.pop() },
|
||||
onBack = {
|
||||
if (backStack.size > 1) {
|
||||
navigationManager.pop()
|
||||
} else {
|
||||
showExitDialog = true
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(backgroundDark)
|
||||
@@ -232,9 +251,19 @@ class PurefinActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
BackHandler {
|
||||
showExitDialog = true
|
||||
}
|
||||
LoginScreen(
|
||||
modifier = Modifier.semantics { testTagsAsResourceId = true }
|
||||
)
|
||||
}
|
||||
|
||||
if (showExitDialog) {
|
||||
ExitAppDialog(
|
||||
onCloseConfirmed = { finishAndRemoveTask() },
|
||||
onDismiss = { showExitDialog = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.os.Bundle
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
@@ -17,6 +18,8 @@ import hu.bbara.purefin.ui.theme.AppTheme
|
||||
|
||||
@AndroidEntryPoint
|
||||
class PlayerActivity : ComponentActivity() {
|
||||
private val viewModel: PlayerViewModel by viewModels()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
@@ -43,6 +46,18 @@ class PlayerActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
if (!isChangingConfigurations) {
|
||||
viewModel.pausePlayback()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
enterImmersiveMode()
|
||||
}
|
||||
|
||||
private fun enterImmersiveMode() {
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
WindowInsetsControllerCompat(window, window.decorView).apply {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package hu.bbara.purefin.ui.common.card
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
@@ -18,11 +21,13 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.MoreVert
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -32,6 +37,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -41,6 +47,7 @@ import androidx.compose.ui.unit.dp
|
||||
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
|
||||
import hu.bbara.purefin.ui.model.MediaAction
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MediaImageCard(
|
||||
imageUrl: String?,
|
||||
@@ -60,13 +67,19 @@ fun MediaImageCard(
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val shape = RoundedCornerShape(shapeSize)
|
||||
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
var showBottomSheet by remember { mutableStateOf(false) }
|
||||
|
||||
Card(
|
||||
onClick = onClick,
|
||||
shape = shape,
|
||||
colors = CardDefaults.cardColors(containerColor = scheme.surfaceContainer),
|
||||
modifier = modifier.fillMaxWidth()
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.combinedClickable(
|
||||
onClick = onClick,
|
||||
onLongClick = if (popupActions.isNotEmpty()) {
|
||||
{ showBottomSheet = true }
|
||||
} else null
|
||||
)
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Box(
|
||||
@@ -115,29 +128,39 @@ fun MediaImageCard(
|
||||
}
|
||||
}
|
||||
if (popupActions.isNotEmpty()) {
|
||||
Box() {
|
||||
IconButton(
|
||||
onClick = { expanded = !expanded },
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.MoreVert,
|
||||
contentDescription = "More actions"
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false },
|
||||
) {
|
||||
popupActions.forEach { action ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(text = action.name) },
|
||||
onClick = action.onClick
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(
|
||||
onClick = { showBottomSheet = true },
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.MoreVert,
|
||||
contentDescription = "More actions"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showBottomSheet) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { showBottomSheet = false },
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 24.dp)
|
||||
) {
|
||||
popupActions.forEach { action ->
|
||||
ListItem(
|
||||
headlineContent = { Text(text = action.name) },
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
modifier = Modifier.clickable {
|
||||
action.onClick()
|
||||
showBottomSheet = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,93 +5,389 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.sizeIn
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.CheckCircle
|
||||
import androidx.compose.material.icons.outlined.Person
|
||||
import androidx.compose.material.icons.outlined.RadioButtonUnchecked
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.material3.VerticalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import hu.bbara.purefin.core.download.DownloadState
|
||||
import hu.bbara.purefin.model.CastMember
|
||||
import hu.bbara.purefin.ui.common.button.DownloadActionButton
|
||||
import hu.bbara.purefin.ui.common.button.MediaActionButton
|
||||
import hu.bbara.purefin.ui.common.button.MediaResumeButton
|
||||
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
|
||||
|
||||
data class MediaDetailScaffoldUiModel(
|
||||
val imageUrl: String,
|
||||
val title: String,
|
||||
val imageContentDescription: String? = null,
|
||||
val titleFontSize: TextUnit = 32.sp,
|
||||
val titleLineHeight: TextUnit = 38.sp,
|
||||
val subtitle: String? = null,
|
||||
val subtitleFontSize: TextUnit = 14.sp,
|
||||
val subtitleFontWeight: FontWeight = FontWeight.Medium,
|
||||
val metadataItems: List<String> = emptyList(),
|
||||
val highlightedMetadataItem: String? = null,
|
||||
val actions: MediaDetailActionsUiModel? = null,
|
||||
val synopsis: MediaDetailSynopsisUiModel? = null,
|
||||
val playbackSettings: MediaDetailPlaybackSettingsUiModel? = null,
|
||||
val cast: MediaDetailCastUiModel? = null,
|
||||
)
|
||||
|
||||
data class MediaDetailActionsUiModel(
|
||||
val primaryAction: MediaDetailPrimaryActionUiModel,
|
||||
val secondaryActions: List<MediaDetailSecondaryActionUiModel> = emptyList(),
|
||||
val dividerThickness: Dp? = null,
|
||||
)
|
||||
|
||||
data class MediaDetailPrimaryActionUiModel(
|
||||
val text: String,
|
||||
val progress: Float,
|
||||
val onClick: () -> Unit,
|
||||
val testTag: String? = null,
|
||||
)
|
||||
|
||||
sealed interface MediaDetailSecondaryActionUiModel {
|
||||
val testTag: String?
|
||||
|
||||
data class Download(
|
||||
val downloadState: DownloadState,
|
||||
val onClick: () -> Unit,
|
||||
override val testTag: String? = null,
|
||||
) : MediaDetailSecondaryActionUiModel
|
||||
|
||||
data class Icon(
|
||||
val icon: ImageVector,
|
||||
val onClick: () -> Unit = {},
|
||||
override val testTag: String? = null,
|
||||
) : MediaDetailSecondaryActionUiModel
|
||||
|
||||
data class MarkAsWatched(
|
||||
val watched: Boolean,
|
||||
val onClick: () -> Unit,
|
||||
override val testTag: String? = null,
|
||||
) : MediaDetailSecondaryActionUiModel
|
||||
}
|
||||
|
||||
data class MediaDetailSynopsisUiModel(
|
||||
val text: String,
|
||||
val bodyColor: Color? = null,
|
||||
val bodyFontSize: TextUnit = 15.sp,
|
||||
val bodyLineHeight: TextUnit? = 22.sp,
|
||||
val titleSpacing: Dp = 12.dp,
|
||||
)
|
||||
|
||||
data class MediaDetailPlaybackSettingsUiModel(
|
||||
val audioTrack: String,
|
||||
val subtitles: String,
|
||||
)
|
||||
|
||||
data class MediaDetailCastUiModel(
|
||||
val members: List<CastMember>,
|
||||
val title: String = "Cast",
|
||||
val topSpacing: Dp = 0.dp,
|
||||
val cardWidth: Dp = 96.dp,
|
||||
val nameFontSize: TextUnit = 12.sp,
|
||||
val roleFontSize: TextUnit = 10.sp,
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MediaDetailScaffold(
|
||||
imageUrl: String,
|
||||
uiModel: MediaDetailScaffoldUiModel,
|
||||
modifier: Modifier = Modifier,
|
||||
imageHeight: Dp = 320.dp,
|
||||
contentOverlap: Dp = 24.dp,
|
||||
contentSpacing: Dp = 16.dp,
|
||||
topBar: @Composable () -> Unit = {},
|
||||
heroContent: @Composable ColumnScope.() -> Unit,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
topBar: @Composable (scrollBehavior: TopAppBarScrollBehavior) -> Unit = {},
|
||||
content: @Composable ColumnScope.(Modifier) -> Unit = {}
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val isHomeMediaTransitionActive = isHomeMediaSharedBoundsTransitionActive()
|
||||
val topBarScrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
|
||||
Box(
|
||||
Scaffold(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(scheme.background)
|
||||
) {
|
||||
Box(
|
||||
.nestedScroll(topBarScrollBehavior.nestedScrollConnection),
|
||||
containerColor = scheme.background,
|
||||
topBar = {
|
||||
if (!isHomeMediaTransitionActive) {
|
||||
topBar(topBarScrollBehavior)
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.homeMediaSharedBoundsDestination()
|
||||
.fillMaxWidth()
|
||||
.height(imageHeight)
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(bottom = innerPadding.calculateBottomPadding()),
|
||||
verticalArrangement = Arrangement.spacedBy(contentSpacing)
|
||||
) {
|
||||
PurefinAsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color.Transparent,
|
||||
Color.Transparent,
|
||||
scheme.background.copy(alpha = 0.5f),
|
||||
scheme.background
|
||||
.homeMediaSharedBoundsDestination()
|
||||
.fillMaxWidth()
|
||||
.height(imageHeight)
|
||||
) {
|
||||
PurefinAsyncImage(
|
||||
model = uiModel.imageUrl,
|
||||
contentDescription = uiModel.imageContentDescription,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color.Transparent,
|
||||
Color.Transparent,
|
||||
scheme.background.copy(alpha = 0.5f),
|
||||
scheme.background
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
containerColor = Color.Transparent,
|
||||
topBar = {
|
||||
if (!isHomeMediaTransitionActive) {
|
||||
topBar()
|
||||
}
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
if (!isHomeMediaTransitionActive) {
|
||||
val contentModifier = Modifier.padding(horizontal = 16.dp)
|
||||
MediaDetailScaffoldContent(
|
||||
uiModel = uiModel,
|
||||
modifier = contentModifier
|
||||
)
|
||||
content(contentModifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.MediaDetailScaffoldContent(
|
||||
uiModel: MediaDetailScaffoldUiModel,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
|
||||
Text(
|
||||
text = uiModel.title,
|
||||
color = scheme.onBackground,
|
||||
fontSize = uiModel.titleFontSize,
|
||||
fontWeight = FontWeight.Bold,
|
||||
lineHeight = uiModel.titleLineHeight,
|
||||
modifier = modifier
|
||||
)
|
||||
uiModel.subtitle?.takeIf { it.isNotBlank() }?.let { subtitle ->
|
||||
Text(
|
||||
text = subtitle,
|
||||
color = scheme.onBackground,
|
||||
fontSize = uiModel.subtitleFontSize,
|
||||
fontWeight = uiModel.subtitleFontWeight,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
if (uiModel.metadataItems.any { it.isNotBlank() }) {
|
||||
MediaMetadataFlowRow(
|
||||
items = uiModel.metadataItems,
|
||||
highlightedItem = uiModel.highlightedMetadataItem,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
uiModel.actions?.let { actions ->
|
||||
MediaDetailActions(
|
||||
uiModel = actions,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
uiModel.synopsis?.takeIf { it.text.isNotBlank() }?.let { synopsis ->
|
||||
MediaSynopsis(
|
||||
synopsis = synopsis.text,
|
||||
bodyColor = synopsis.bodyColor ?: scheme.onSurfaceVariant,
|
||||
bodyFontSize = synopsis.bodyFontSize,
|
||||
bodyLineHeight = synopsis.bodyLineHeight,
|
||||
titleSpacing = synopsis.titleSpacing,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
uiModel.playbackSettings?.let { playbackSettings ->
|
||||
MediaPlaybackSettings(
|
||||
backgroundColor = scheme.surface,
|
||||
foregroundColor = scheme.onSurface,
|
||||
audioTrack = playbackSettings.audioTrack,
|
||||
subtitles = playbackSettings.subtitles,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
uiModel.cast?.takeIf { it.members.isNotEmpty() }?.let { cast ->
|
||||
if (cast.topSpacing > 0.dp) {
|
||||
Spacer(modifier = Modifier.height(cast.topSpacing))
|
||||
}
|
||||
Text(
|
||||
text = cast.title,
|
||||
color = scheme.onBackground,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = modifier
|
||||
)
|
||||
Spacer(modifier = modifier.height(12.dp))
|
||||
MediaDetailCastRow(
|
||||
cast = cast,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MediaDetailCastRow(
|
||||
cast: MediaDetailCastUiModel,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val mutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.7f)
|
||||
|
||||
LazyRow(
|
||||
modifier = modifier,
|
||||
contentPadding = PaddingValues(horizontal = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
items(cast.members) { member ->
|
||||
Column(modifier = Modifier.width(cast.cardWidth)) {
|
||||
PurefinAsyncImage(
|
||||
model = member.imageUrl,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.aspectRatio(4f / 5f)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(scheme.surfaceVariant),
|
||||
contentScale = ContentScale.Crop,
|
||||
fallbackIcon = Icons.Outlined.Person
|
||||
)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Text(
|
||||
text = member.name,
|
||||
color = scheme.onBackground,
|
||||
fontSize = cast.nameFontSize,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = member.role,
|
||||
color = mutedStrong,
|
||||
fontSize = cast.roleFontSize,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MediaDetailActions(
|
||||
uiModel: MediaDetailActionsUiModel,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(modifier = modifier) {
|
||||
MediaResumeButton(
|
||||
text = uiModel.primaryAction.text,
|
||||
progress = uiModel.primaryAction.progress,
|
||||
onClick = uiModel.primaryAction.onClick,
|
||||
modifier = Modifier
|
||||
.sizeIn(maxWidth = 200.dp)
|
||||
.optionalTestTag(uiModel.primaryAction.testTag)
|
||||
)
|
||||
uiModel.dividerThickness?.let { dividerThickness ->
|
||||
VerticalDivider(
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
thickness = dividerThickness,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(bottom = innerPadding.calculateBottomPadding())
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(top = imageHeight - contentOverlap),
|
||||
verticalArrangement = Arrangement.spacedBy(contentSpacing)
|
||||
) {
|
||||
if (!isHomeMediaTransitionActive) {
|
||||
heroContent()
|
||||
content()
|
||||
.height(48.dp)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
)
|
||||
} ?: Spacer(modifier = Modifier.width(12.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
uiModel.secondaryActions.forEach { action ->
|
||||
when (action) {
|
||||
is MediaDetailSecondaryActionUiModel.Download -> {
|
||||
DownloadActionButton(
|
||||
downloadState = action.downloadState,
|
||||
onClick = action.onClick,
|
||||
modifier = Modifier.optionalTestTag(action.testTag)
|
||||
)
|
||||
}
|
||||
|
||||
is MediaDetailSecondaryActionUiModel.Icon -> {
|
||||
MediaActionButton(
|
||||
backgroundColor = MaterialTheme.colorScheme.surface,
|
||||
iconColor = MaterialTheme.colorScheme.onSurface,
|
||||
icon = action.icon,
|
||||
height = 48.dp,
|
||||
onClick = action.onClick,
|
||||
modifier = Modifier.optionalTestTag(action.testTag)
|
||||
)
|
||||
}
|
||||
|
||||
is MediaDetailSecondaryActionUiModel.MarkAsWatched -> {
|
||||
MediaActionButton(
|
||||
backgroundColor = MaterialTheme.colorScheme.surface,
|
||||
iconColor = if (action.watched) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
},
|
||||
icon = if (action.watched) {
|
||||
Icons.Outlined.CheckCircle
|
||||
} else {
|
||||
Icons.Outlined.RadioButtonUnchecked
|
||||
},
|
||||
height = 48.dp,
|
||||
onClick = action.onClick,
|
||||
modifier = Modifier.optionalTestTag(action.testTag)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Modifier.optionalTestTag(testTag: String?): Modifier =
|
||||
if (testTag == null) this else then(Modifier.testTag(testTag))
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package hu.bbara.purefin.ui.common.permission
|
||||
|
||||
import android.Manifest
|
||||
import android.os.Build
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
|
||||
@Composable
|
||||
fun rememberNotificationPermissionGate(): (action: () -> Unit) -> Unit {
|
||||
val pendingAction = remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) {
|
||||
// Proceed regardless — notification permission is nice-to-have for downloads.
|
||||
val action = pendingAction.value
|
||||
pendingAction.value = null
|
||||
action?.invoke()
|
||||
}
|
||||
|
||||
return remember(launcher) {
|
||||
{ action ->
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
pendingAction.value = action
|
||||
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
} else {
|
||||
action()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package hu.bbara.purefin.ui.screen
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
@@ -12,11 +13,11 @@ import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation3.runtime.NavBackStack
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import androidx.navigation3.runtime.entryProvider
|
||||
@@ -45,6 +46,7 @@ fun AppScreen(
|
||||
val nextUp by viewModel.nextUp.collectAsStateWithLifecycle()
|
||||
val isRefreshing by viewModel.isRefreshing.collectAsStateWithLifecycle()
|
||||
val isOnline by viewModel.isOnline.collectAsStateWithLifecycle()
|
||||
val isCheckingConnection by viewModel.isCheckingConnection.collectAsStateWithLifecycle()
|
||||
val isCheckingForUpdates by updateViewModel.isCheckingForUpdates.collectAsStateWithLifecycle()
|
||||
val availableUpdate by updateViewModel.availableUpdate.collectAsStateWithLifecycle()
|
||||
val navigationManager = LocalNavigationManager.current
|
||||
@@ -132,12 +134,18 @@ fun AppScreen(
|
||||
DownloadsScreen(
|
||||
selectedTab = selectedTab,
|
||||
isOnline = isOnline,
|
||||
isCheckingConnection = isCheckingConnection,
|
||||
onCheckConnection = viewModel::checkConnection,
|
||||
onTabSelected = onTabSelected,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler(enabled = backStack.size == 1) {
|
||||
navigationManager.pop()
|
||||
}
|
||||
|
||||
NavDisplay(
|
||||
backStack = backStack,
|
||||
onBack = {
|
||||
|
||||
@@ -2,17 +2,23 @@ package hu.bbara.purefin.ui.screen.download
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import hu.bbara.purefin.ui.screen.download.components.DownloadsContent
|
||||
import hu.bbara.purefin.ui.screen.AppBottomBar
|
||||
import hu.bbara.purefin.ui.screen.download.components.DownloadsContent
|
||||
import hu.bbara.purefin.ui.screen.home.components.DefaultTopBar
|
||||
import hu.bbara.purefin.ui.screen.home.components.DefaultTopBarTextButton
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DownloadsScreen(
|
||||
selectedTab: Int,
|
||||
isOnline: Boolean,
|
||||
isCheckingConnection: Boolean,
|
||||
onCheckConnection: () -> Unit,
|
||||
onTabSelected: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
@@ -20,6 +26,19 @@ fun DownloadsScreen(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
contentColor = MaterialTheme.colorScheme.onBackground,
|
||||
topBar = {
|
||||
DefaultTopBar(
|
||||
rightActions = {
|
||||
if (!isOnline) {
|
||||
DefaultTopBarTextButton(
|
||||
text = if (isCheckingConnection) "Checking…" else "Check connection",
|
||||
onClick = onCheckConnection,
|
||||
enabled = !isCheckingConnection
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
AppBottomBar(
|
||||
selectedTab = selectedTab,
|
||||
|
||||
@@ -5,14 +5,17 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Download
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@@ -24,8 +27,8 @@ import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import hu.bbara.purefin.ui.common.card.PosterCard
|
||||
import hu.bbara.purefin.core.feature.downloads.DownloadsViewModel
|
||||
import hu.bbara.purefin.ui.common.card.PosterCard
|
||||
|
||||
@Composable
|
||||
fun DownloadsContent(
|
||||
@@ -101,14 +104,17 @@ fun DownloadsContent(
|
||||
}
|
||||
}
|
||||
}
|
||||
items(downloads.value, key = { item -> item.id }) { item ->
|
||||
PosterCard(
|
||||
item = item,
|
||||
onMovieSelected = viewModel::onMovieSelected,
|
||||
onSeriesSelected = viewModel::onSeriesSelected,
|
||||
onEpisodeSelected = { _, _, _ -> },
|
||||
modifier = Modifier.testTag("$DownloadsItemTagPrefix${item.id}")
|
||||
)
|
||||
items(downloads.value, key = { item -> item.media.id }) { item ->
|
||||
Column(
|
||||
modifier = Modifier.testTag("$DownloadsItemTagPrefix${item.media.id}")
|
||||
) {
|
||||
PosterCard(
|
||||
item = item.media,
|
||||
onMovieSelected = viewModel::onMovieSelected,
|
||||
onSeriesSelected = viewModel::onSeriesSelected,
|
||||
onEpisodeSelected = { _, _, _ -> },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
package hu.bbara.purefin.ui.screen.episode
|
||||
|
||||
import android.Manifest
|
||||
import android.os.Build
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import android.content.Intent
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import hu.bbara.purefin.core.download.DownloadState
|
||||
import hu.bbara.purefin.core.feature.content.episode.EpisodeScreenViewModel
|
||||
import hu.bbara.purefin.core.image.ArtworkKind
|
||||
@@ -24,9 +20,21 @@ import hu.bbara.purefin.core.navigation.Route
|
||||
import hu.bbara.purefin.model.CastMember
|
||||
import hu.bbara.purefin.model.Episode
|
||||
import hu.bbara.purefin.navigation.LocalNavigationBackStack
|
||||
import hu.bbara.purefin.player.PlayerActivity
|
||||
import hu.bbara.purefin.ui.common.media.MediaDetailActionsUiModel
|
||||
import hu.bbara.purefin.ui.common.media.MediaDetailCastUiModel
|
||||
import hu.bbara.purefin.ui.common.media.MediaDetailPlaybackSettingsUiModel
|
||||
import hu.bbara.purefin.ui.common.media.MediaDetailPrimaryActionUiModel
|
||||
import hu.bbara.purefin.ui.common.media.MediaDetailScaffold
|
||||
import hu.bbara.purefin.ui.common.media.MediaMetadataFlowRow
|
||||
import hu.bbara.purefin.ui.screen.episode.components.EpisodeDetails
|
||||
import hu.bbara.purefin.ui.common.media.MediaDetailScaffoldUiModel
|
||||
import hu.bbara.purefin.ui.common.media.MediaDetailSecondaryActionUiModel
|
||||
import hu.bbara.purefin.ui.common.media.MediaDetailSynopsisUiModel
|
||||
import hu.bbara.purefin.ui.common.media.mediaPlayButtonText
|
||||
import hu.bbara.purefin.ui.common.media.mediaPlaybackProgress
|
||||
import hu.bbara.purefin.ui.common.permission.rememberNotificationPermissionGate
|
||||
import hu.bbara.purefin.ui.screen.episode.components.EpisodeDownloadButtonTag
|
||||
import hu.bbara.purefin.ui.screen.episode.components.EpisodePlayButtonTag
|
||||
import hu.bbara.purefin.ui.screen.episode.components.EpisodeWatchedButtonTag
|
||||
import hu.bbara.purefin.ui.screen.episode.components.EpisodeTopBar
|
||||
import hu.bbara.purefin.ui.screen.episode.components.EpisodeTopBarShortcut
|
||||
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
|
||||
@@ -48,19 +56,13 @@ fun EpisodeScreen(
|
||||
|
||||
val episode = viewModel.episode.collectAsStateWithLifecycle()
|
||||
val downloadState = viewModel.downloadState.collectAsStateWithLifecycle()
|
||||
|
||||
val notificationPermissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { _ ->
|
||||
// Proceed with download regardless — notification is nice-to-have
|
||||
viewModel.onDownloadClick()
|
||||
}
|
||||
val requestNotificationPermission = rememberNotificationPermissionGate()
|
||||
|
||||
val onDownloadClick = {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
|
||||
&& downloadState.value is DownloadState.NotDownloaded
|
||||
) {
|
||||
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
if (downloadState.value is DownloadState.NotDownloaded) {
|
||||
requestNotificationPermission {
|
||||
viewModel.onDownloadClick()
|
||||
}
|
||||
} else {
|
||||
viewModel.onDownloadClick()
|
||||
}
|
||||
@@ -82,10 +84,12 @@ fun EpisodeScreen(
|
||||
downloadState = downloadState.value,
|
||||
onBack = viewModel::onBack,
|
||||
onDownloadClick = onDownloadClick,
|
||||
onMarkAsWatched = viewModel::markAsWatched,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun EpisodeScreenInternal(
|
||||
episode: Episode,
|
||||
@@ -93,59 +97,79 @@ private fun EpisodeScreenInternal(
|
||||
downloadState: DownloadState,
|
||||
onBack: () -> Unit,
|
||||
onDownloadClick: () -> Unit,
|
||||
onMarkAsWatched: (Boolean) -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val playAction = remember(episode.id) {
|
||||
{
|
||||
val intent = Intent(context, PlayerActivity::class.java)
|
||||
intent.putExtra("MEDIA_ID", episode.id.toString())
|
||||
context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
MediaDetailScaffold(
|
||||
imageUrl = ImageUrlBuilder.finishImageUrl(episode.imageUrlPrefix, ArtworkKind.PRIMARY),
|
||||
uiModel = episode.toMediaDetailScaffoldUiModel(
|
||||
onPlayClick = playAction,
|
||||
downloadState = downloadState,
|
||||
onDownloadClick = onDownloadClick,
|
||||
onMarkAsWatched = onMarkAsWatched,
|
||||
),
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
topBar = { scrollBehavior ->
|
||||
EpisodeTopBar(
|
||||
shortcut = topBarShortcut,
|
||||
onBack = onBack,
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
},
|
||||
heroContent = {
|
||||
EpisodeHeroContent(episode = episode)
|
||||
}
|
||||
) {
|
||||
EpisodeDetails(
|
||||
episode = episode,
|
||||
downloadState = downloadState,
|
||||
onDownloadClick = onDownloadClick,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun Episode.toMediaDetailScaffoldUiModel(
|
||||
onPlayClick: () -> Unit,
|
||||
downloadState: DownloadState,
|
||||
onDownloadClick: () -> Unit,
|
||||
onMarkAsWatched: (Boolean) -> Unit,
|
||||
): MediaDetailScaffoldUiModel = MediaDetailScaffoldUiModel(
|
||||
imageUrl = ImageUrlBuilder.finishImageUrl(imageUrlPrefix, ArtworkKind.PRIMARY),
|
||||
title = title,
|
||||
subtitle = "Episode $index",
|
||||
metadataItems = listOf(releaseDate, rating, runtime, format),
|
||||
highlightedMetadataItem = format,
|
||||
actions = MediaDetailActionsUiModel(
|
||||
primaryAction = MediaDetailPrimaryActionUiModel(
|
||||
text = mediaPlayButtonText(progress, watched),
|
||||
progress = mediaPlaybackProgress(progress),
|
||||
onClick = onPlayClick,
|
||||
testTag = EpisodePlayButtonTag
|
||||
),
|
||||
secondaryActions = listOf(
|
||||
MediaDetailSecondaryActionUiModel.MarkAsWatched(
|
||||
watched = watched,
|
||||
onClick = { onMarkAsWatched(!watched) },
|
||||
testTag = EpisodeWatchedButtonTag
|
||||
),
|
||||
MediaDetailSecondaryActionUiModel.Download(
|
||||
downloadState = downloadState,
|
||||
onClick = onDownloadClick,
|
||||
testTag = EpisodeDownloadButtonTag
|
||||
)
|
||||
),
|
||||
dividerThickness = 2.dp
|
||||
),
|
||||
synopsis = MediaDetailSynopsisUiModel(text = synopsis),
|
||||
playbackSettings = MediaDetailPlaybackSettingsUiModel(
|
||||
audioTrack = "ENG",
|
||||
subtitles = "ENG"
|
||||
),
|
||||
cast = if (cast.isNotEmpty()) {
|
||||
MediaDetailCastUiModel(members = cast)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EpisodeHeroContent(
|
||||
episode: Episode,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
|
||||
Text(
|
||||
text = episode.title,
|
||||
color = scheme.onBackground,
|
||||
fontSize = 32.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
lineHeight = 38.sp
|
||||
)
|
||||
Text(
|
||||
text = "Episode ${episode.index}",
|
||||
color = scheme.onBackground,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
EpisodeMetaChips(episode = episode)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EpisodeMetaChips(episode: Episode) {
|
||||
MediaMetadataFlowRow(
|
||||
items = listOf(episode.releaseDate, episode.rating, episode.runtime, episode.format),
|
||||
highlightedItem = episode.format
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
|
||||
@@ -1,44 +1,26 @@
|
||||
package hu.bbara.purefin.ui.screen.episode.components
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.sizeIn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.ArrowBack
|
||||
import androidx.compose.material.icons.outlined.Cast
|
||||
import androidx.compose.material.icons.outlined.Close
|
||||
import androidx.compose.material.icons.outlined.Download
|
||||
import androidx.compose.material.icons.outlined.DownloadDone
|
||||
import androidx.compose.material.icons.outlined.MoreVert
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.VerticalDivider
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import hu.bbara.purefin.core.download.DownloadState
|
||||
import hu.bbara.purefin.model.Episode
|
||||
import hu.bbara.purefin.player.PlayerActivity
|
||||
import hu.bbara.purefin.ui.common.button.GhostIconButton
|
||||
import hu.bbara.purefin.ui.common.button.MediaActionButton
|
||||
import hu.bbara.purefin.ui.common.button.MediaResumeButton
|
||||
import hu.bbara.purefin.ui.common.media.MediaPlaybackSettings
|
||||
import hu.bbara.purefin.ui.common.media.MediaSynopsis
|
||||
import hu.bbara.purefin.ui.common.media.mediaPlayButtonText
|
||||
import hu.bbara.purefin.ui.common.media.mediaPlaybackProgress
|
||||
import hu.bbara.purefin.ui.screen.home.components.DefaultTopBar
|
||||
|
||||
internal sealed interface EpisodeTopBarShortcut {
|
||||
@@ -50,10 +32,12 @@ internal sealed interface EpisodeTopBarShortcut {
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun EpisodeTopBar(
|
||||
shortcut: EpisodeTopBarShortcut?,
|
||||
onBack: () -> Unit,
|
||||
scrollBehavior: TopAppBarScrollBehavior? = null,
|
||||
) {
|
||||
DefaultTopBar(
|
||||
leftActions = {
|
||||
@@ -96,84 +80,12 @@ internal fun EpisodeTopBar(
|
||||
contentDescription = "More",
|
||||
onClick = { })
|
||||
},
|
||||
withIcon = false
|
||||
withIcon = false,
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun EpisodeDetails(
|
||||
episode: Episode,
|
||||
downloadState: DownloadState,
|
||||
onDownloadClick: () -> Unit,
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
|
||||
val context = LocalContext.current
|
||||
val playAction = remember(episode.id) {
|
||||
{
|
||||
val intent = Intent(context, PlayerActivity::class.java)
|
||||
intent.putExtra("MEDIA_ID", episode.id.toString())
|
||||
context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
MediaSynopsis(
|
||||
synopsis = episode.synopsis
|
||||
)
|
||||
Row() {
|
||||
MediaResumeButton(
|
||||
text = mediaPlayButtonText(episode.progress, episode.watched),
|
||||
progress = mediaPlaybackProgress(episode.progress),
|
||||
onClick = playAction,
|
||||
modifier = Modifier
|
||||
.sizeIn(maxWidth = 200.dp)
|
||||
.testTag(EpisodePlayButtonTag)
|
||||
)
|
||||
VerticalDivider(
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
thickness = 2.dp,
|
||||
modifier = Modifier
|
||||
.height(48.dp)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
)
|
||||
Row() {
|
||||
MediaActionButton(
|
||||
backgroundColor = MaterialTheme.colorScheme.surface,
|
||||
iconColor = MaterialTheme.colorScheme.onSurface,
|
||||
icon = when (downloadState) {
|
||||
is DownloadState.NotDownloaded -> Icons.Outlined.Download
|
||||
is DownloadState.Downloading -> Icons.Outlined.Close
|
||||
is DownloadState.Downloaded -> Icons.Outlined.DownloadDone
|
||||
is DownloadState.Failed -> Icons.Outlined.Download
|
||||
},
|
||||
height = 48.dp,
|
||||
modifier = Modifier.testTag(EpisodeDownloadButtonTag),
|
||||
onClick = onDownloadClick
|
||||
)
|
||||
}
|
||||
}
|
||||
MediaPlaybackSettings(
|
||||
backgroundColor = MaterialTheme.colorScheme.surface,
|
||||
foregroundColor = MaterialTheme.colorScheme.onSurface,
|
||||
//TODO fix it
|
||||
audioTrack = "ENG",
|
||||
subtitles = "ENG"
|
||||
)
|
||||
if (episode.cast.isNotEmpty()) {
|
||||
Text(
|
||||
text = "Cast",
|
||||
color = scheme.onBackground,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
//TODO use it
|
||||
// MediaCastRow(
|
||||
// cast = episode.cast
|
||||
// )
|
||||
}
|
||||
}
|
||||
|
||||
internal const val EpisodeSeriesButtonTag = "episode-series-button"
|
||||
internal const val EpisodePlayButtonTag = "episode-play-button"
|
||||
internal const val EpisodeDownloadButtonTag = "episode-download-button"
|
||||
internal const val EpisodeWatchedButtonTag = "episode-watched-button"
|
||||
|
||||
@@ -2,19 +2,23 @@ package hu.bbara.purefin.ui.screen.home
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import hu.bbara.purefin.core.model.LibraryUiModel
|
||||
import hu.bbara.purefin.core.model.MediaUiModel
|
||||
import hu.bbara.purefin.ui.screen.AppBottomBar
|
||||
import hu.bbara.purefin.ui.screen.home.components.HomeContent
|
||||
import hu.bbara.purefin.ui.screen.home.components.HomeTopBar
|
||||
import hu.bbara.purefin.ui.screen.home.components.rememberDefaultTopBarScrollBehavior
|
||||
import java.util.UUID
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HomeScreen(
|
||||
libraries: List<LibraryUiModel>,
|
||||
@@ -39,9 +43,12 @@ fun HomeScreen(
|
||||
onTabSelected: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val topBarScrollBehavior = rememberDefaultTopBarScrollBehavior()
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier
|
||||
.fillMaxSize(),
|
||||
.fillMaxSize()
|
||||
.nestedScroll(topBarScrollBehavior.nestedScrollConnection),
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
contentColor = MaterialTheme.colorScheme.onBackground,
|
||||
topBar = {
|
||||
@@ -51,7 +58,8 @@ fun HomeScreen(
|
||||
onCheckForUpdates = onCheckForUpdates,
|
||||
isCheckingForUpdates = isCheckingForUpdates,
|
||||
onSettingsClick = onSettingsClick,
|
||||
onLogoutClick = onLogoutClick
|
||||
onLogoutClick = onLogoutClick,
|
||||
scrollBehavior = topBarScrollBehavior
|
||||
)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
|
||||
@@ -5,21 +5,26 @@ import androidx.compose.animation.SharedTransitionScope
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Search
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonColors
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -32,58 +37,58 @@ import hu.bbara.purefin.navigation.LocalNavSharedAnimatedVisibilityScope
|
||||
import hu.bbara.purefin.navigation.LocalSharedTransitionScope
|
||||
import hu.bbara.purefin.ui.common.image.PurefinLogo
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun rememberDefaultTopBarScrollBehavior(): TopAppBarScrollBehavior =
|
||||
TopAppBarDefaults.pinnedScrollBehavior()
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DefaultTopBar(
|
||||
leftActions: (@Composable RowScope.() -> Unit)? = null,
|
||||
rightActions: (@Composable RowScope.() -> Unit)? = null,
|
||||
withIcon: Boolean = true
|
||||
withIcon: Boolean = true,
|
||||
scrollBehavior: TopAppBarScrollBehavior? = null
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
|
||||
Surface(
|
||||
color = Color.Transparent,
|
||||
contentColor = scheme.onSurface,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.statusBarsPadding()
|
||||
.height(84.dp)
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
TopAppBar(
|
||||
title = {
|
||||
if (withIcon) {
|
||||
PurefinLogo(
|
||||
modifier = Modifier.size(56.dp),
|
||||
)
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
if (leftActions != null) {
|
||||
Row(
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
content = leftActions
|
||||
)
|
||||
}
|
||||
if (withIcon) {
|
||||
Row(
|
||||
modifier = Modifier,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = when {
|
||||
leftActions == null && rightActions != null -> Arrangement.Start
|
||||
leftActions != null && rightActions == null -> Arrangement.Center
|
||||
else -> Arrangement.Center
|
||||
}
|
||||
) {
|
||||
PurefinLogo(
|
||||
modifier = Modifier.size(56.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (rightActions != null) {
|
||||
Row(
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
content = rightActions
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = Color.Transparent,
|
||||
scrolledContainerColor = scheme.background,
|
||||
navigationIconContentColor = scheme.onSurface,
|
||||
titleContentColor = scheme.onSurface,
|
||||
actionIconContentColor = scheme.onSurface
|
||||
),
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -114,6 +119,37 @@ fun DefaultTopBarIconButton(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DefaultTopBarTextButton(
|
||||
text: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
|
||||
TextButton(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
shape = CircleShape,
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
containerColor = scheme.surface,
|
||||
contentColor = scheme.onSurface,
|
||||
disabledContainerColor = scheme.surface,
|
||||
disabledContentColor = scheme.onSurface
|
||||
),
|
||||
contentPadding = PaddingValues(horizontal = 18.dp),
|
||||
modifier = modifier
|
||||
.height(50.dp)
|
||||
.clip(CircleShape),
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
fun DefaultTopBarSearchButton(
|
||||
|
||||
@@ -121,11 +121,12 @@ fun HomeContent(
|
||||
|
||||
if (nextUp.isNotEmpty()) {
|
||||
item(key = "next-up") {
|
||||
NextUpSection(
|
||||
items = nextUp,
|
||||
onMediaSelected = onMediaSelected,
|
||||
modifier = Modifier.testTag(HomeNextUpSectionTag)
|
||||
)
|
||||
NextUpSection(
|
||||
items = nextUp,
|
||||
onMediaSelected = onMediaSelected,
|
||||
onMarkAsWatched = onMarkAsWatched,
|
||||
modifier = Modifier.testTag(HomeNextUpSectionTag)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,6 +139,7 @@ fun HomeContent(
|
||||
items = libraryContent[library.id].orEmpty(),
|
||||
onLibrarySelected = onLibrarySelected,
|
||||
onMediaSelected = onMediaSelected,
|
||||
onMarkAsWatched = onMarkAsWatched,
|
||||
modifier = Modifier.testTag("$HomeLibrarySectionTagPrefix${library.id}")
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,13 +4,16 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Person
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HomeTopBar(
|
||||
onSearchClick: () -> Unit,
|
||||
@@ -19,10 +22,12 @@ fun HomeTopBar(
|
||||
isCheckingForUpdates: Boolean,
|
||||
onSettingsClick: () -> Unit,
|
||||
onLogoutClick: () -> Unit,
|
||||
scrollBehavior: TopAppBarScrollBehavior? = null,
|
||||
) {
|
||||
var isProfileMenuExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
DefaultTopBar(
|
||||
scrollBehavior = scrollBehavior,
|
||||
rightActions = {
|
||||
DefaultTopBarSearchButton(onClick = onSearchClick)
|
||||
DefaultTopBarIconButton(
|
||||
|
||||
@@ -10,28 +10,41 @@ import hu.bbara.purefin.core.model.EpisodeUiModel
|
||||
import hu.bbara.purefin.core.model.MediaUiModel
|
||||
import hu.bbara.purefin.core.model.MovieUiModel
|
||||
import hu.bbara.purefin.core.model.SeriesUiModel
|
||||
import androidx.compose.runtime.remember
|
||||
import hu.bbara.purefin.ui.common.badge.UnwatchedEpisodeBadge
|
||||
import hu.bbara.purefin.ui.common.badge.WatchStateBadge
|
||||
import hu.bbara.purefin.ui.common.card.MediaImageCard
|
||||
import hu.bbara.purefin.ui.common.media.homeMediaSharedBoundsSource
|
||||
import hu.bbara.purefin.ui.common.media.rememberHomeMediaSharedBoundsClick
|
||||
import hu.bbara.purefin.ui.model.MediaAction
|
||||
|
||||
@Composable
|
||||
internal fun HomeBrowseCard(
|
||||
uiModel: MediaUiModel,
|
||||
sharedBoundsKey: String,
|
||||
onMediaSelected: (MediaUiModel) -> Unit,
|
||||
onMarkAsWatched: (MediaUiModel, Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val onClick = rememberHomeMediaSharedBoundsClick(sharedBoundsKey) {
|
||||
onMediaSelected(uiModel)
|
||||
}
|
||||
|
||||
val popupActions = remember(uiModel, onMarkAsWatched) {
|
||||
buildList {
|
||||
if (uiModel is MovieUiModel || uiModel is EpisodeUiModel || uiModel is SeriesUiModel) {
|
||||
add(MediaAction(name = "Mark as watched") { onMarkAsWatched(uiModel, true) })
|
||||
add(MediaAction(name = "Mark as unwatched") { onMarkAsWatched(uiModel, false) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MediaImageCard(
|
||||
imageUrl = uiModel.primaryImageUrl,
|
||||
title = uiModel.primaryText,
|
||||
subtitle = uiModel.secondaryText,
|
||||
onClick = onClick,
|
||||
popupActions = popupActions,
|
||||
imageModifier = Modifier.homeMediaSharedBoundsSource(sharedBoundsKey),
|
||||
imageAspectRatio = 15f / 16f,
|
||||
modifier = modifier.width(188.dp)
|
||||
|
||||
@@ -25,6 +25,7 @@ fun LibraryPosterSection(
|
||||
items: List<MediaUiModel>,
|
||||
onLibrarySelected: (LibraryUiModel) -> Unit,
|
||||
onMediaSelected: (MediaUiModel) -> Unit,
|
||||
onMarkAsWatched: (MediaUiModel, Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (items.isEmpty()) return
|
||||
@@ -61,6 +62,7 @@ fun LibraryPosterSection(
|
||||
mediaId = item.id
|
||||
),
|
||||
onMediaSelected = onMediaSelected,
|
||||
onMarkAsWatched = onMarkAsWatched,
|
||||
modifier = Modifier.testTag("$HomeLibraryItemTagPrefix${library.id}-$index")
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -13,24 +14,39 @@ import androidx.compose.ui.unit.dp
|
||||
import hu.bbara.purefin.ui.common.card.MediaImageCard
|
||||
import hu.bbara.purefin.ui.common.media.homeMediaSharedBoundsSource
|
||||
import hu.bbara.purefin.ui.common.media.rememberHomeMediaSharedBoundsClick
|
||||
import hu.bbara.purefin.ui.model.MediaAction
|
||||
import hu.bbara.purefin.core.model.MediaUiModel
|
||||
import hu.bbara.purefin.core.model.MovieUiModel
|
||||
import hu.bbara.purefin.core.model.EpisodeUiModel
|
||||
import hu.bbara.purefin.core.model.SeriesUiModel
|
||||
|
||||
@Composable
|
||||
internal fun NextUpCard(
|
||||
uiModel: MediaUiModel,
|
||||
sharedBoundsKey: String,
|
||||
onMediaSelected: (MediaUiModel) -> Unit,
|
||||
onMarkAsWatched: (MediaUiModel, Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val onClick = rememberHomeMediaSharedBoundsClick(sharedBoundsKey) {
|
||||
onMediaSelected(uiModel)
|
||||
}
|
||||
|
||||
val popupActions = remember(uiModel, onMarkAsWatched) {
|
||||
buildList {
|
||||
if (uiModel is MovieUiModel || uiModel is EpisodeUiModel || uiModel is SeriesUiModel) {
|
||||
add(MediaAction(name = "Mark as watched") { onMarkAsWatched(uiModel, true) })
|
||||
add(MediaAction(name = "Mark as unwatched") { onMarkAsWatched(uiModel, false) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MediaImageCard(
|
||||
imageUrl = uiModel.primaryImageUrl,
|
||||
title = uiModel.primaryText,
|
||||
subtitle = uiModel.secondaryText,
|
||||
onClick = onClick,
|
||||
popupActions = popupActions,
|
||||
imageModifier = Modifier.homeMediaSharedBoundsSource(sharedBoundsKey),
|
||||
shapeSize = 24.dp,
|
||||
titleStyle = MaterialTheme.typography.titleSmall,
|
||||
|
||||
@@ -22,6 +22,7 @@ import hu.bbara.purefin.core.model.MediaUiModel
|
||||
fun NextUpSection(
|
||||
items: List<MediaUiModel>,
|
||||
onMediaSelected: (MediaUiModel) -> Unit,
|
||||
onMarkAsWatched: (MediaUiModel, Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (items.isEmpty()) return
|
||||
@@ -51,6 +52,7 @@ fun NextUpSection(
|
||||
uiModel = item,
|
||||
sharedBoundsKey = homeMediaSharedBoundsKey("next-up-$index", item.id),
|
||||
onMediaSelected = onMediaSelected,
|
||||
onMarkAsWatched = onMarkAsWatched,
|
||||
modifier = Modifier.testTag("$HomeNextUpItemTagPrefix$index")
|
||||
)
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import androidx.compose.material.icons.outlined.Close
|
||||
import androidx.compose.material.icons.outlined.Search
|
||||
import androidx.compose.material.icons.outlined.Tune
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -38,14 +39,20 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -64,6 +71,7 @@ import hu.bbara.purefin.navigation.LocalNavSharedAnimatedVisibilityScope
|
||||
import hu.bbara.purefin.navigation.LocalSharedTransitionScope
|
||||
import hu.bbara.purefin.ui.screen.home.components.DefaultTopBar
|
||||
import hu.bbara.purefin.ui.screen.home.components.DefaultTopBarIconButton
|
||||
import hu.bbara.purefin.ui.screen.home.components.rememberDefaultTopBarScrollBehavior
|
||||
import hu.bbara.purefin.ui.theme.AppTheme
|
||||
import java.util.UUID
|
||||
|
||||
@@ -126,7 +134,7 @@ fun SearchScreen(
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun SearchScreenContent(
|
||||
query: String,
|
||||
@@ -142,13 +150,17 @@ private fun SearchScreenContent(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val topBarScrollBehavior = rememberDefaultTopBarScrollBehavior()
|
||||
|
||||
Scaffold(
|
||||
topBar = { SearchHeader(
|
||||
onBack = onBack
|
||||
onBack = onBack,
|
||||
scrollBehavior = topBarScrollBehavior
|
||||
) },
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.testTag(SearchScreenTag)
|
||||
.nestedScroll(topBarScrollBehavior.nestedScrollConnection)
|
||||
.background(scheme.background)
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
@@ -216,10 +228,13 @@ private fun SearchResults(
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
private fun SearchHeader(
|
||||
onBack: () -> Unit,
|
||||
scrollBehavior: TopAppBarScrollBehavior? = null,
|
||||
) {
|
||||
DefaultTopBar(
|
||||
scrollBehavior = scrollBehavior,
|
||||
leftActions = {
|
||||
DefaultTopBarIconButton(
|
||||
imageVector = Icons.Outlined.ArrowBack,
|
||||
@@ -239,6 +254,11 @@ private fun SearchField(
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
|
||||
TextField(
|
||||
value = query,
|
||||
@@ -291,6 +311,7 @@ private fun SearchField(
|
||||
),
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester)
|
||||
.testTag(SearchQueryFieldTag)
|
||||
.height(68.dp)
|
||||
)
|
||||
|
||||
@@ -2,15 +2,19 @@ package hu.bbara.purefin.ui.screen.libraries
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import hu.bbara.purefin.core.model.LibraryUiModel
|
||||
import hu.bbara.purefin.ui.screen.AppBottomBar
|
||||
import hu.bbara.purefin.ui.screen.home.components.rememberDefaultTopBarScrollBehavior
|
||||
import hu.bbara.purefin.ui.screen.libraries.components.LibrariesContent
|
||||
import hu.bbara.purefin.ui.screen.libraries.components.LibrariesTopBar
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LibrariesScreen(
|
||||
items: List<LibraryUiModel>,
|
||||
@@ -21,13 +25,18 @@ fun LibrariesScreen(
|
||||
onTabSelected: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val topBarScrollBehavior = rememberDefaultTopBarScrollBehavior()
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.nestedScroll(topBarScrollBehavior.nestedScrollConnection),
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
contentColor = MaterialTheme.colorScheme.onBackground,
|
||||
topBar = {
|
||||
LibrariesTopBar(
|
||||
onSearchClick = onSearchClick,
|
||||
scrollBehavior = topBarScrollBehavior
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
package hu.bbara.purefin.ui.screen.libraries.components
|
||||
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.runtime.Composable
|
||||
import hu.bbara.purefin.ui.screen.home.components.DefaultTopBar
|
||||
import hu.bbara.purefin.ui.screen.home.components.DefaultTopBarSearchButton
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LibrariesTopBar(
|
||||
onSearchClick: () -> Unit,
|
||||
scrollBehavior: TopAppBarScrollBehavior? = null,
|
||||
) {
|
||||
DefaultTopBar(
|
||||
scrollBehavior = scrollBehavior,
|
||||
rightActions = {
|
||||
DefaultTopBarSearchButton(onClick = onSearchClick)
|
||||
}
|
||||
|
||||
@@ -10,13 +10,16 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
@@ -28,8 +31,11 @@ import hu.bbara.purefin.core.model.SeriesUiModel
|
||||
import hu.bbara.purefin.core.navigation.LibraryDto
|
||||
import hu.bbara.purefin.ui.common.badge.WatchStateBadge
|
||||
import hu.bbara.purefin.ui.common.card.MediaImageCard
|
||||
import hu.bbara.purefin.ui.model.MediaAction
|
||||
import hu.bbara.purefin.ui.screen.home.components.rememberDefaultTopBarScrollBehavior
|
||||
import hu.bbara.purefin.ui.screen.library.components.LibraryTopBar
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LibraryScreen(
|
||||
library: LibraryDto,
|
||||
@@ -41,14 +47,18 @@ fun LibraryScreen(
|
||||
}
|
||||
|
||||
val libraryItems = viewModel.contents.collectAsStateWithLifecycle()
|
||||
val topBarScrollBehavior = rememberDefaultTopBarScrollBehavior()
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier.testTag(LibraryScreenTag),
|
||||
modifier = modifier
|
||||
.testTag(LibraryScreenTag)
|
||||
.nestedScroll(topBarScrollBehavior.nestedScrollConnection),
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
contentColor = MaterialTheme.colorScheme.onBackground,
|
||||
topBar = {
|
||||
LibraryTopBar(
|
||||
onBack = { viewModel.onBack() }
|
||||
onBack = { viewModel.onBack() },
|
||||
scrollBehavior = topBarScrollBehavior
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
@@ -58,7 +68,10 @@ fun LibraryScreen(
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.padding(innerPadding)
|
||||
) {
|
||||
LibraryPosterGrid(libraryItems = libraryItems.value)
|
||||
LibraryPosterGrid(
|
||||
libraryItems = libraryItems.value,
|
||||
onMarkAsWatched = viewModel::markAsWatched,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -67,6 +80,7 @@ fun LibraryScreen(
|
||||
internal fun LibraryPosterGrid(
|
||||
libraryItems: List<MediaUiModel>,
|
||||
modifier: Modifier = Modifier,
|
||||
onMarkAsWatched: (MediaUiModel, Boolean) -> Unit,
|
||||
viewModel: LibraryViewModel = hiltViewModel()
|
||||
) {
|
||||
BoxWithConstraints(
|
||||
@@ -83,6 +97,15 @@ internal fun LibraryPosterGrid(
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
items(libraryItems, key = { item -> item.id }) { item ->
|
||||
val popupActions = remember {
|
||||
buildList {
|
||||
if (item is MovieUiModel || item is EpisodeUiModel || item is SeriesUiModel) {
|
||||
add(MediaAction(name = "Mark as watched") { onMarkAsWatched(item, true) })
|
||||
add(MediaAction(name = "Mark as unwatched") { onMarkAsWatched(item, false) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MediaImageCard(
|
||||
imageUrl = item.primaryImageUrl,
|
||||
title = item.primaryText,
|
||||
@@ -94,6 +117,7 @@ internal fun LibraryPosterGrid(
|
||||
is EpisodeUiModel -> Unit
|
||||
}
|
||||
},
|
||||
popupActions = popupActions,
|
||||
modifier = Modifier.testTag("$LibraryPosterItemTagPrefix${item.id}")
|
||||
) {
|
||||
when (item) {
|
||||
|
||||
@@ -3,15 +3,20 @@ package hu.bbara.purefin.ui.screen.library.components
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
|
||||
import androidx.compose.material.icons.outlined.FilterAlt
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.runtime.Composable
|
||||
import hu.bbara.purefin.ui.screen.home.components.DefaultTopBar
|
||||
import hu.bbara.purefin.ui.screen.home.components.DefaultTopBarIconButton
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LibraryTopBar(
|
||||
onBack: () -> Unit,
|
||||
scrollBehavior: TopAppBarScrollBehavior? = null,
|
||||
) {
|
||||
DefaultTopBar(
|
||||
scrollBehavior = scrollBehavior,
|
||||
leftActions = {
|
||||
DefaultTopBarIconButton(
|
||||
imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
package hu.bbara.purefin.ui.screen.movie
|
||||
|
||||
import android.Manifest
|
||||
import android.os.Build
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import android.content.Intent
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import hu.bbara.purefin.core.download.DownloadState
|
||||
import hu.bbara.purefin.core.feature.content.movie.MovieScreenViewModel
|
||||
import hu.bbara.purefin.core.image.ArtworkKind
|
||||
@@ -21,9 +18,21 @@ import hu.bbara.purefin.core.image.ImageUrlBuilder
|
||||
import hu.bbara.purefin.core.navigation.MovieDto
|
||||
import hu.bbara.purefin.model.CastMember
|
||||
import hu.bbara.purefin.model.Movie
|
||||
import hu.bbara.purefin.player.PlayerActivity
|
||||
import hu.bbara.purefin.ui.common.media.MediaDetailActionsUiModel
|
||||
import hu.bbara.purefin.ui.common.media.MediaDetailCastUiModel
|
||||
import hu.bbara.purefin.ui.common.media.MediaDetailPlaybackSettingsUiModel
|
||||
import hu.bbara.purefin.ui.common.media.MediaDetailPrimaryActionUiModel
|
||||
import hu.bbara.purefin.ui.common.media.MediaDetailScaffold
|
||||
import hu.bbara.purefin.ui.common.media.MediaMetadataFlowRow
|
||||
import hu.bbara.purefin.ui.screen.movie.components.MovieDetails
|
||||
import hu.bbara.purefin.ui.common.media.MediaDetailScaffoldUiModel
|
||||
import hu.bbara.purefin.ui.common.media.MediaDetailSecondaryActionUiModel
|
||||
import hu.bbara.purefin.ui.common.media.MediaDetailSynopsisUiModel
|
||||
import hu.bbara.purefin.ui.common.media.mediaPlayButtonText
|
||||
import hu.bbara.purefin.ui.screen.movie.components.MovieWatchedButtonTag
|
||||
import hu.bbara.purefin.ui.common.media.mediaPlaybackProgress
|
||||
import hu.bbara.purefin.ui.common.permission.rememberNotificationPermissionGate
|
||||
import hu.bbara.purefin.ui.screen.movie.components.MovieDownloadButtonTag
|
||||
import hu.bbara.purefin.ui.screen.movie.components.MoviePlayButtonTag
|
||||
import hu.bbara.purefin.ui.screen.movie.components.MovieTopBar
|
||||
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
|
||||
import hu.bbara.purefin.ui.theme.AppTheme
|
||||
@@ -41,19 +50,13 @@ fun MovieScreen(
|
||||
|
||||
val movieItem = viewModel.movie.collectAsStateWithLifecycle()
|
||||
val downloadState = viewModel.downloadState.collectAsStateWithLifecycle()
|
||||
|
||||
val notificationPermissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { granted ->
|
||||
// Proceed with download regardless — notification is nice-to-have
|
||||
viewModel.onDownloadClick()
|
||||
}
|
||||
val requestNotificationPermission = rememberNotificationPermissionGate()
|
||||
|
||||
val onDownloadClick = {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
|
||||
&& downloadState.value is DownloadState.NotDownloaded
|
||||
) {
|
||||
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
if (downloadState.value is DownloadState.NotDownloaded) {
|
||||
requestNotificationPermission {
|
||||
viewModel.onDownloadClick()
|
||||
}
|
||||
} else {
|
||||
viewModel.onDownloadClick()
|
||||
}
|
||||
@@ -65,6 +68,7 @@ fun MovieScreen(
|
||||
downloadState = downloadState.value,
|
||||
onDownloadClick = onDownloadClick,
|
||||
onBack = viewModel::onBack,
|
||||
onMarkAsWatched = viewModel::markAsWatched,
|
||||
modifier = modifier
|
||||
)
|
||||
} else {
|
||||
@@ -72,52 +76,87 @@ fun MovieScreen(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun MovieScreenInternal(
|
||||
movie: Movie,
|
||||
downloadState: DownloadState = DownloadState.NotDownloaded,
|
||||
onDownloadClick: () -> Unit = {},
|
||||
onBack: () -> Unit,
|
||||
onMarkAsWatched: (Boolean) -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
MediaDetailScaffold(
|
||||
imageUrl = ImageUrlBuilder.finishImageUrl(movie.imageUrlPrefix, ArtworkKind.PRIMARY),
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
MovieTopBar(onBack = onBack)
|
||||
},
|
||||
heroContent = {
|
||||
MovieHeroContent(movie = movie)
|
||||
val context = LocalContext.current
|
||||
val playAction = remember(movie.id) {
|
||||
{
|
||||
val intent = Intent(context, PlayerActivity::class.java)
|
||||
intent.putExtra("MEDIA_ID", movie.id.toString())
|
||||
context.startActivity(intent)
|
||||
}
|
||||
) {
|
||||
MovieDetails(
|
||||
movie = movie,
|
||||
}
|
||||
|
||||
MediaDetailScaffold(
|
||||
uiModel = movie.toMediaDetailScaffoldUiModel(
|
||||
onPlayClick = playAction,
|
||||
downloadState = downloadState,
|
||||
onDownloadClick = onDownloadClick,
|
||||
onMarkAsWatched = onMarkAsWatched,
|
||||
),
|
||||
modifier = modifier,
|
||||
topBar = { scrollBehavior ->
|
||||
MovieTopBar(
|
||||
onBack = onBack,
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun Movie.toMediaDetailScaffoldUiModel(
|
||||
onPlayClick: () -> Unit,
|
||||
downloadState: DownloadState,
|
||||
onDownloadClick: () -> Unit,
|
||||
onMarkAsWatched: (Boolean) -> Unit,
|
||||
): MediaDetailScaffoldUiModel = MediaDetailScaffoldUiModel(
|
||||
imageUrl = ImageUrlBuilder.finishImageUrl(imageUrlPrefix, ArtworkKind.PRIMARY),
|
||||
title = title,
|
||||
metadataItems = listOf(year, rating, runtime, format),
|
||||
highlightedMetadataItem = format,
|
||||
actions = MediaDetailActionsUiModel(
|
||||
primaryAction = MediaDetailPrimaryActionUiModel(
|
||||
text = mediaPlayButtonText(progress, watched),
|
||||
progress = mediaPlaybackProgress(progress),
|
||||
onClick = onPlayClick,
|
||||
testTag = MoviePlayButtonTag
|
||||
),
|
||||
secondaryActions = listOf(
|
||||
MediaDetailSecondaryActionUiModel.MarkAsWatched(
|
||||
watched = watched,
|
||||
onClick = { onMarkAsWatched(!watched) },
|
||||
testTag = MovieWatchedButtonTag
|
||||
),
|
||||
MediaDetailSecondaryActionUiModel.Download(
|
||||
downloadState = downloadState,
|
||||
onClick = onDownloadClick,
|
||||
testTag = MovieDownloadButtonTag
|
||||
)
|
||||
),
|
||||
dividerThickness = 4.dp
|
||||
),
|
||||
synopsis = MediaDetailSynopsisUiModel(text = synopsis),
|
||||
playbackSettings = MediaDetailPlaybackSettingsUiModel(
|
||||
audioTrack = "ENG",
|
||||
subtitles = "ENG"
|
||||
),
|
||||
cast = if (cast.isNotEmpty()) {
|
||||
MediaDetailCastUiModel(
|
||||
members = cast,
|
||||
topSpacing = 24.dp
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MovieHeroContent(
|
||||
movie: Movie,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
|
||||
Text(
|
||||
text = movie.title,
|
||||
color = scheme.onBackground,
|
||||
fontSize = 32.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
lineHeight = 38.sp
|
||||
)
|
||||
// Spacer(modifier = Modifier.height(8.dp))
|
||||
MediaMetadataFlowRow(
|
||||
items = listOf(movie.year, movie.rating, movie.runtime, movie.format),
|
||||
highlightedItem = movie.format
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
|
||||
@@ -1,45 +1,20 @@
|
||||
package hu.bbara.purefin.ui.screen.movie.components
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.sizeIn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.ArrowBack
|
||||
import androidx.compose.material.icons.outlined.Cast
|
||||
import androidx.compose.material.icons.outlined.Close
|
||||
import androidx.compose.material.icons.outlined.Download
|
||||
import androidx.compose.material.icons.outlined.DownloadDone
|
||||
import androidx.compose.material.icons.outlined.MoreVert
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.VerticalDivider
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import hu.bbara.purefin.core.download.DownloadState
|
||||
import hu.bbara.purefin.model.Movie
|
||||
import hu.bbara.purefin.player.PlayerActivity
|
||||
import hu.bbara.purefin.ui.common.button.GhostIconButton
|
||||
import hu.bbara.purefin.ui.common.button.MediaActionButton
|
||||
import hu.bbara.purefin.ui.common.button.MediaResumeButton
|
||||
import hu.bbara.purefin.ui.common.media.MediaPlaybackSettings
|
||||
import hu.bbara.purefin.ui.common.media.MediaSynopsis
|
||||
import hu.bbara.purefin.ui.common.media.mediaPlayButtonText
|
||||
import hu.bbara.purefin.ui.common.media.mediaPlaybackProgress
|
||||
import hu.bbara.purefin.ui.screen.home.components.DefaultTopBar
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun MovieTopBar(
|
||||
onBack: () -> Unit,
|
||||
scrollBehavior: TopAppBarScrollBehavior? = null,
|
||||
) {
|
||||
DefaultTopBar(
|
||||
leftActions = {
|
||||
@@ -56,84 +31,11 @@ internal fun MovieTopBar(
|
||||
contentDescription = "More",
|
||||
onClick = { })
|
||||
},
|
||||
withIcon = false
|
||||
withIcon = false,
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
internal fun MovieDetails(
|
||||
movie: Movie,
|
||||
downloadState: DownloadState,
|
||||
onDownloadClick: () -> Unit,
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
|
||||
val context = LocalContext.current
|
||||
val playAction = remember(movie.id) {
|
||||
{
|
||||
val intent = Intent(context, PlayerActivity::class.java)
|
||||
intent.putExtra("MEDIA_ID", movie.id.toString())
|
||||
context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
MediaSynopsis(
|
||||
synopsis = movie.synopsis
|
||||
)
|
||||
Row() {
|
||||
MediaResumeButton(
|
||||
text = mediaPlayButtonText(movie.progress, movie.watched),
|
||||
progress = mediaPlaybackProgress(movie.progress),
|
||||
onClick = playAction,
|
||||
modifier = Modifier
|
||||
.sizeIn(maxWidth = 200.dp)
|
||||
.testTag(MoviePlayButtonTag)
|
||||
)
|
||||
VerticalDivider(
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
thickness = 4.dp,
|
||||
modifier = Modifier
|
||||
.height(48.dp)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
)
|
||||
Row() {
|
||||
MediaActionButton(
|
||||
backgroundColor = MaterialTheme.colorScheme.surface,
|
||||
iconColor = MaterialTheme.colorScheme.onSurface,
|
||||
icon = when (downloadState) {
|
||||
is DownloadState.NotDownloaded -> Icons.Outlined.Download
|
||||
is DownloadState.Downloading -> Icons.Outlined.Close
|
||||
is DownloadState.Downloaded -> Icons.Outlined.DownloadDone
|
||||
is DownloadState.Failed -> Icons.Outlined.Download
|
||||
},
|
||||
height = 48.dp,
|
||||
modifier = Modifier.testTag(MovieDownloadButtonTag),
|
||||
onClick = onDownloadClick
|
||||
)
|
||||
}
|
||||
}
|
||||
MediaPlaybackSettings(
|
||||
backgroundColor = MaterialTheme.colorScheme.surface,
|
||||
foregroundColor = MaterialTheme.colorScheme.onSurface,
|
||||
audioTrack = "ENG",
|
||||
subtitles = "ENG"
|
||||
)
|
||||
if (movie.cast.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Text(
|
||||
text = "Cast",
|
||||
color = scheme.onBackground,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
//TODO fix
|
||||
// MediaCastRow(
|
||||
// cast = movie.cast,
|
||||
// )
|
||||
}
|
||||
}
|
||||
|
||||
internal const val MoviePlayButtonTag = "movie-play-button"
|
||||
internal const val MovieDownloadButtonTag = "movie-download-button"
|
||||
internal const val MovieWatchedButtonTag = "movie-watched-button"
|
||||
|
||||
@@ -12,6 +12,7 @@ import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -23,12 +24,10 @@ import androidx.compose.material.icons.outlined.VolumeUp
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -37,18 +36,17 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.VideoSize
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.ui.AspectRatioFrameLayout
|
||||
import androidx.media3.ui.PlayerView
|
||||
import androidx.media3.ui.SubtitleView
|
||||
import hu.bbara.purefin.core.player.model.TimedMarker
|
||||
import hu.bbara.purefin.core.player.viewmodel.PlayerViewModel
|
||||
import hu.bbara.purefin.model.SegmentType
|
||||
import hu.bbara.purefin.ui.common.visual.EmptyValueTimedVisibility
|
||||
import hu.bbara.purefin.ui.common.visual.ValueChangeTimedVisibility
|
||||
import hu.bbara.purefin.ui.screen.player.components.NextEpisodeOverlay
|
||||
import hu.bbara.purefin.ui.screen.player.components.PersistentOverlayContainer
|
||||
import hu.bbara.purefin.ui.screen.player.components.PlayerAdjustmentIndicator
|
||||
import hu.bbara.purefin.ui.screen.player.components.PlayerControlsOverlay
|
||||
@@ -59,13 +57,16 @@ import hu.bbara.purefin.ui.screen.player.components.PlayerSeekBarTrack
|
||||
import hu.bbara.purefin.ui.screen.player.components.PlayerTimeRow
|
||||
import hu.bbara.purefin.ui.screen.player.components.SkipSegmentButton
|
||||
import hu.bbara.purefin.ui.screen.player.components.rememberPersistentOverlayController
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private const val CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION = 0.32f
|
||||
private const val WIDE_VIDEO_ASPECT_RATIO = 16f / 9f
|
||||
/** Small negative band below zero that represents adaptive/auto brightness mode. */
|
||||
private const val AUTO_BRIGHTNESS_SENTINEL = -0.1f
|
||||
private const val AUTO_BRIGHTNESS_THRESHOLD = -0.15f
|
||||
private const val CONTROLS_AUTO_HIDE_MS = 3_000L
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@Composable
|
||||
@@ -73,8 +74,13 @@ fun PlayerScreen(
|
||||
viewModel: PlayerViewModel,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var hideControlsJob: Job? by remember { mutableStateOf(null) }
|
||||
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val controlsVisible by viewModel.controlsVisible.collectAsStateWithLifecycle()
|
||||
var controlsVisible by remember { mutableStateOf(true) }
|
||||
var controlsHasPopupOpen by remember { mutableStateOf(false) }
|
||||
|
||||
val context = LocalContext.current
|
||||
val activity = context as? Activity
|
||||
|
||||
@@ -85,40 +91,45 @@ fun PlayerScreen(
|
||||
var showQueuePanel by remember { mutableStateOf(false) }
|
||||
var horizontalSeekFeedback by remember { mutableStateOf<Long?>(null) }
|
||||
var horizontalSeekPreviewPositionMs by remember { mutableStateOf<Long?>(null) }
|
||||
var videoAspectRatio by remember { mutableStateOf(calculateVideoAspectRatio(viewModel.player.videoSize)) }
|
||||
val overlayController = rememberPersistentOverlayController()
|
||||
|
||||
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
|
||||
viewModel.pausePlayback()
|
||||
}
|
||||
|
||||
DisposableEffect(viewModel.player) {
|
||||
val listener = object : Player.Listener {
|
||||
override fun onVideoSizeChanged(videoSize: VideoSize) {
|
||||
videoAspectRatio = calculateVideoAspectRatio(videoSize)
|
||||
}
|
||||
}
|
||||
viewModel.player.addListener(listener)
|
||||
onDispose {
|
||||
viewModel.player.removeListener(listener)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(uiState.isPlaying) {
|
||||
if (uiState.isPlaying) {
|
||||
showQueuePanel = false
|
||||
}
|
||||
}
|
||||
|
||||
val playerControlsVisible = controlsVisible || uiState.isEnded || uiState.error != null
|
||||
val playerResizeMode = playerResizeMode(videoAspectRatio)
|
||||
val subtitleBottomPaddingFraction =
|
||||
if (playerControlsVisible) {
|
||||
if (controlsVisible) {
|
||||
CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION
|
||||
} else {
|
||||
SubtitleView.DEFAULT_BOTTOM_PADDING_FRACTION
|
||||
}
|
||||
|
||||
|
||||
val showSkipIntroButton = !controlsVisible && uiState.activeSkippableSegmentEndMs != null && uiState.activeSkippableSegmentType == SegmentType.INTRO
|
||||
val showNextEpisodeOverlay = !controlsVisible
|
||||
&& uiState.nextEpisode != null
|
||||
&& uiState.durationMs > 0L
|
||||
&& ((uiState.durationMs - uiState.positionMs) <= 60_000L || uiState.activeSkippableSegmentType == SegmentType.OUTRO)
|
||||
&& !uiState.isEnded
|
||||
|
||||
|
||||
fun toggleControlsVisibility() {
|
||||
controlsVisible = !controlsVisible
|
||||
}
|
||||
fun hideControls() {
|
||||
controlsVisible = false
|
||||
}
|
||||
fun hideControlsWithTimeout() {
|
||||
hideControlsJob?.cancel()
|
||||
if (controlsVisible && !controlsHasPopupOpen && !uiState.isEnded && uiState.error == null) {
|
||||
hideControlsJob = scope.launch {
|
||||
delay(CONTROLS_AUTO_HIDE_MS)
|
||||
hideControls()
|
||||
}
|
||||
}
|
||||
}
|
||||
fun onScreenTap() {
|
||||
toggleControlsVisibility()
|
||||
hideControlsWithTimeout()
|
||||
}
|
||||
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -128,29 +139,29 @@ fun PlayerScreen(
|
||||
factory = { ctx ->
|
||||
PlayerView(ctx).apply {
|
||||
useController = false
|
||||
resizeMode = playerResizeMode
|
||||
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||
player = viewModel.player
|
||||
subtitleView?.setBottomPaddingFraction(subtitleBottomPaddingFraction)
|
||||
}
|
||||
},
|
||||
update = {
|
||||
it.player = viewModel.player
|
||||
it.resizeMode = playerResizeMode
|
||||
it.subtitleView?.setBottomPaddingFraction(subtitleBottomPaddingFraction)
|
||||
onRelease = { playerView ->
|
||||
playerView.player = null
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.fillMaxHeight()
|
||||
.align(Alignment.Center)
|
||||
)
|
||||
|
||||
PlayerGesturesLayer(
|
||||
modifier = Modifier.align(Alignment.BottomCenter),
|
||||
onTap = { viewModel.toggleControlsVisibility() },
|
||||
controlsVisible = controlsVisible,
|
||||
onTap = ::onScreenTap,
|
||||
onDoubleTapRight = { viewModel.seekBy(30_000) },
|
||||
onDoubleTapLeft = { viewModel.seekBy(-10_000) },
|
||||
onDoubleTapCenter = { viewModel.togglePlayPause() },
|
||||
onVerticalDragLeft = { delta ->
|
||||
val diff = (-delta / 800f)
|
||||
brightness = (brightness + diff).coerceIn(AUTO_BRIGHTNESS_SENTINEL, 1f)
|
||||
brightness = (brightness + diff).coerceIn(AUTO_BRIGHTNESS_THRESHOLD, 1f)
|
||||
applyBrightness(activity, brightness)
|
||||
},
|
||||
onVerticalDragRight = { delta ->
|
||||
@@ -175,7 +186,7 @@ fun PlayerScreen(
|
||||
onHorizontalDragSeekTo = {
|
||||
viewModel.seekTo(it)
|
||||
},
|
||||
currentPositionProvider = { uiState.positionMs }
|
||||
currentPositionProvider = { uiState.positionMs },
|
||||
)
|
||||
|
||||
EmptyValueTimedVisibility(
|
||||
@@ -193,7 +204,7 @@ fun PlayerScreen(
|
||||
value = horizontalSeekPreviewPositionMs,
|
||||
hideAfterMillis = 1_000
|
||||
) { previewPositionMs ->
|
||||
if (!playerControlsVisible) {
|
||||
if (controlsVisible) {
|
||||
HiddenSeekTimeline(
|
||||
positionMs = previewPositionMs,
|
||||
durationMs = uiState.durationMs,
|
||||
@@ -216,11 +227,17 @@ fun PlayerScreen(
|
||||
.padding(start = 20.dp)
|
||||
) { currentBrightness ->
|
||||
PlayerAdjustmentIndicator(
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center),
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
icon = Icons.Outlined.BrightnessMedium,
|
||||
contentDescription = "Brightness",
|
||||
value = currentBrightness
|
||||
value = currentBrightness,
|
||||
bottomText = if (currentBrightness <= AUTO_BRIGHTNESS_THRESHOLD) {
|
||||
"Auto"
|
||||
} else if (currentBrightness >= 0f) {
|
||||
"${(currentBrightness * 100).roundToInt()}%"
|
||||
} else {
|
||||
null
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -236,19 +253,19 @@ fun PlayerScreen(
|
||||
.align(Alignment.Center),
|
||||
icon = Icons.Outlined.VolumeUp,
|
||||
contentDescription = "Volume",
|
||||
value = currentVolume
|
||||
value = currentVolume,
|
||||
bottomText = "${(currentVolume.coerceIn(0f, 1f) * 100).roundToInt()}%"
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = playerControlsVisible,
|
||||
visible = controlsVisible,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
) {
|
||||
PlayerControlsOverlay(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
uiState = uiState,
|
||||
showControls = controlsVisible,
|
||||
overlayController = overlayController,
|
||||
onBack = onBack,
|
||||
onPlayPause = { viewModel.togglePlayPause() },
|
||||
@@ -259,23 +276,45 @@ fun PlayerScreen(
|
||||
onNext = { viewModel.next() },
|
||||
onPrevious = { viewModel.previous() },
|
||||
onSelectTrack = { viewModel.selectTrack(it) },
|
||||
onQueueSelected = { viewModel.playQueueItem(it) },
|
||||
onOpenQueue = { showQueuePanel = true }
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = !playerControlsVisible && uiState.activeSkippableSegmentEndMs != null,
|
||||
visible = showSkipIntroButton,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterEnd)
|
||||
.padding(
|
||||
end = 24.dp,
|
||||
)
|
||||
) {
|
||||
SkipSegmentButton(
|
||||
size = 72.dp,
|
||||
fontSize = 22,
|
||||
onClick = { viewModel.skipActiveSegment() }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = showNextEpisodeOverlay,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(
|
||||
end = 24.dp,
|
||||
bottom = if (horizontalSeekPreviewPositionMs != null) 96.dp else 24.dp
|
||||
bottom = 24.dp
|
||||
)
|
||||
) {
|
||||
SkipSegmentButton(onClick = { viewModel.skipActiveSegment() })
|
||||
uiState.nextEpisode?.let { nextEpisode ->
|
||||
NextEpisodeOverlay(
|
||||
nextEpisode = nextEpisode,
|
||||
onClick = { viewModel.next() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
PlayerLoadingErrorEndCard(
|
||||
@@ -313,21 +352,6 @@ fun PlayerScreen(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
private fun playerResizeMode(videoAspectRatio: Float): Int =
|
||||
if (videoAspectRatio >= WIDE_VIDEO_ASPECT_RATIO) {
|
||||
AspectRatioFrameLayout.RESIZE_MODE_ZOOM
|
||||
} else {
|
||||
AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||
}
|
||||
|
||||
private fun calculateVideoAspectRatio(videoSize: VideoSize): Float {
|
||||
if (videoSize.width <= 0 || videoSize.height <= 0) {
|
||||
return 0f
|
||||
}
|
||||
return videoSize.width * videoSize.pixelWidthHeightRatio / videoSize.height
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HiddenSeekTimeline(
|
||||
positionMs: Long,
|
||||
@@ -402,12 +426,12 @@ private fun formatSeekDelta(deltaMs: Long): String {
|
||||
|
||||
private fun readCurrentBrightness(activity: Activity?): Float {
|
||||
val current = activity?.window?.attributes?.screenBrightness
|
||||
return if (current != null && current >= 0) current else AUTO_BRIGHTNESS_SENTINEL
|
||||
return if (current != null && current >= 0) current else -1f
|
||||
}
|
||||
|
||||
private fun applyBrightness(activity: Activity?, value: Float) {
|
||||
activity ?: return
|
||||
val params = activity.window.attributes
|
||||
params.screenBrightness = if (value < 0f) -1f else value
|
||||
params.screenBrightness = if (value <= AUTO_BRIGHTNESS_THRESHOLD) -1f else value
|
||||
activity.window.attributes = params
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package hu.bbara.purefin.ui.screen.player.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import hu.bbara.purefin.core.player.model.PlaylistElementUiModel
|
||||
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
|
||||
|
||||
/**
|
||||
* Overlay button that appears near the end of a media item to let the user
|
||||
* quickly jump to the next episode. Shows the next episode's artwork and title.
|
||||
*/
|
||||
@Composable
|
||||
fun NextEpisodeOverlay(
|
||||
nextEpisode: PlaylistElementUiModel,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.width(200.dp)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(Color.Black.copy(alpha = 0.85f))
|
||||
.clickable { onClick() },
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp)
|
||||
) {
|
||||
// "Up Next" label
|
||||
Text(
|
||||
text = "Up Next",
|
||||
color = scheme.secondary,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
modifier = Modifier.padding(start = 12.dp, top = 8.dp, end = 12.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
// Thumbnail with play button overlay
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp)
|
||||
.aspectRatio(16f / 9f)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
) {
|
||||
PurefinAsyncImage(
|
||||
model = nextEpisode.artworkUrl,
|
||||
contentDescription = nextEpisode.title,
|
||||
modifier = Modifier.matchParentSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
|
||||
// Dark gradient overlay for readability
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color.Black.copy(alpha = 0.1f),
|
||||
Color.Black.copy(alpha = 0.4f)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Episode title
|
||||
Text(
|
||||
text = nextEpisode.title,
|
||||
color = scheme.onSurface,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 10.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal const val PlayerNextEpisodeOverlayTag = "player_next_episode_overlay"
|
||||
@@ -13,6 +13,8 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -20,7 +22,6 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
fun PlayerAdjustmentIndicator(
|
||||
@@ -28,12 +29,15 @@ fun PlayerAdjustmentIndicator(
|
||||
icon: ImageVector,
|
||||
contentDescription: String?,
|
||||
value: Float,
|
||||
bottomText: String? = null,
|
||||
sliderHeight: Dp = 140.dp,
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val isAuto = value < 0f
|
||||
val percent = if (isAuto) 0 else (value * 100).roundToInt()
|
||||
val clamped = value.coerceIn(0f, 1f)
|
||||
val previousBottomText = remember { mutableStateOf("-") }
|
||||
if (bottomText != null) {
|
||||
previousBottomText.value = bottomText
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
@@ -69,10 +73,9 @@ fun PlayerAdjustmentIndicator(
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
val label = if (isAuto) "Auto" else "$percent%"
|
||||
Text(
|
||||
text = label,
|
||||
color = if (isAuto) scheme.tertiary else scheme.onSurface,
|
||||
text = previousBottomText.value,
|
||||
color = scheme.onSurface,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,11 +10,7 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.ArrowBack
|
||||
import androidx.compose.material.icons.outlined.Cast
|
||||
@@ -26,32 +22,26 @@ import androidx.compose.material.icons.outlined.PlaylistPlay
|
||||
import androidx.compose.material.icons.outlined.Replay10
|
||||
import androidx.compose.material.icons.outlined.SkipNext
|
||||
import androidx.compose.material.icons.outlined.SkipPrevious
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.testTag
|
||||
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.TrackOption
|
||||
import hu.bbara.purefin.ui.common.button.CircularTextButton
|
||||
import hu.bbara.purefin.ui.common.button.GhostIconButton
|
||||
import hu.bbara.purefin.ui.common.button.PurefinIconButton
|
||||
|
||||
@Composable
|
||||
fun PlayerControlsOverlay(
|
||||
uiState: PlayerUiState,
|
||||
showControls: Boolean,
|
||||
overlayController: PersistentOverlayController,
|
||||
onBack: () -> Unit,
|
||||
onPlayPause: () -> Unit,
|
||||
@@ -62,11 +52,9 @@ fun PlayerControlsOverlay(
|
||||
onNext: () -> Unit,
|
||||
onPrevious: () -> Unit,
|
||||
onSelectTrack: (TrackOption) -> Unit,
|
||||
onQueueSelected: (String) -> Unit,
|
||||
onOpenQueue: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var scrubbing by remember { mutableStateOf(false) }
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
@@ -98,11 +86,8 @@ fun PlayerControlsOverlay(
|
||||
)
|
||||
BottomSection(
|
||||
uiState = uiState,
|
||||
scrubbing = scrubbing,
|
||||
overlayController = overlayController,
|
||||
onScrubStart = { scrubbing = true },
|
||||
onScrub = onSeek,
|
||||
onScrubFinished = { scrubbing = false },
|
||||
onNext = onNext,
|
||||
onPrevious = onPrevious,
|
||||
onPlayPause = onPlayPause,
|
||||
@@ -111,7 +96,6 @@ fun PlayerControlsOverlay(
|
||||
onSeekLiveEdge = onSeekLiveEdge,
|
||||
onSkipSegment = onSkipSegment,
|
||||
onSelectTrack = onSelectTrack,
|
||||
onQueueSelected = onQueueSelected,
|
||||
modifier = Modifier.align(Alignment.BottomCenter)
|
||||
)
|
||||
}
|
||||
@@ -163,11 +147,8 @@ private fun TopBar(
|
||||
@Composable
|
||||
private fun BottomSection(
|
||||
uiState: PlayerUiState,
|
||||
scrubbing: Boolean,
|
||||
overlayController: PersistentOverlayController,
|
||||
onScrubStart: () -> Unit,
|
||||
onScrub: (Long) -> Unit,
|
||||
onScrubFinished: () -> Unit,
|
||||
onNext: () -> Unit,
|
||||
onPrevious: () -> Unit,
|
||||
onPlayPause: () -> Unit,
|
||||
@@ -176,7 +157,6 @@ private fun BottomSection(
|
||||
onSeekLiveEdge: () -> Unit,
|
||||
onSkipSegment: () -> Unit,
|
||||
onSelectTrack: (TrackOption) -> Unit,
|
||||
onQueueSelected: (String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
@@ -194,8 +174,6 @@ private fun BottomSection(
|
||||
chapterMarkers = uiState.chapters,
|
||||
adMarkers = uiState.ads,
|
||||
onSeek = onScrub,
|
||||
onScrubStarted = onScrubStart,
|
||||
onScrubFinished = onScrubFinished,
|
||||
modifier = Modifier.testTag(PlayerSeekBarTag)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
@@ -295,32 +273,21 @@ internal const val PlayerSkipSegmentButtonTag = "player_skip_segment_button"
|
||||
@Composable
|
||||
fun SkipSegmentButton(
|
||||
onClick: () -> Unit,
|
||||
fontSize: Int = 18,
|
||||
size: Dp = 52.dp,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
|
||||
FilledTonalButton(
|
||||
CircularTextButton(
|
||||
text = "Skip",
|
||||
textColor = scheme.onSecondary,
|
||||
fontSize = fontSize,
|
||||
containerColor = scheme.secondary,
|
||||
size = size,
|
||||
onClick = onClick,
|
||||
modifier = modifier.heightIn(min = 44.dp),
|
||||
shape = RoundedCornerShape(50),
|
||||
colors = ButtonDefaults.filledTonalButtonColors(
|
||||
containerColor = scheme.secondary.copy(alpha = 0.92f),
|
||||
contentColor = scheme.onSecondary
|
||||
),
|
||||
contentPadding = ButtonDefaults.ButtonWithIconContentPadding
|
||||
) {
|
||||
Text(
|
||||
text = "Skip",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.SkipNext,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
@@ -22,6 +23,7 @@ import kotlin.math.abs
|
||||
@Composable
|
||||
fun PlayerGesturesLayer(
|
||||
modifier: Modifier = Modifier,
|
||||
controlsVisible: Boolean,
|
||||
onTap: () -> Unit,
|
||||
onDoubleTapCenter: () -> Unit,
|
||||
onDoubleTapRight: () -> Unit,
|
||||
@@ -36,6 +38,7 @@ fun PlayerGesturesLayer(
|
||||
val horizontalThresholdPx = with(density) { HorizontalSeekGestureHelper.START_THRESHOLD.toPx() }
|
||||
val directionThresholdPx = with(density) { 20.dp.toPx() }
|
||||
val dragActive = remember { mutableStateOf(false) }
|
||||
val currentControlsVisible = rememberUpdatedState(controlsVisible)
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
@@ -63,6 +66,13 @@ fun PlayerGesturesLayer(
|
||||
val startX = down.position.x
|
||||
dragActive.value = false
|
||||
|
||||
if (currentControlsVisible.value) {
|
||||
// Swipe gestures are disabled while the controls overlay is visible.
|
||||
// Tap gestures (single/double tap) are still handled by the
|
||||
// pointerInput block above.
|
||||
return@awaitEachGesture
|
||||
}
|
||||
|
||||
var accumulatedDrag = Offset.Zero
|
||||
var dragDirection: DragDirection? = null
|
||||
var accumulatedHorizontalDrag = 0f
|
||||
|
||||
@@ -27,8 +27,6 @@ fun PlayerSeekBar(
|
||||
chapterMarkers: List<TimedMarker>,
|
||||
adMarkers: List<TimedMarker>,
|
||||
onSeek: (Long) -> Unit,
|
||||
onScrubStarted: () -> Unit,
|
||||
onScrubFinished: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val safeDuration = durationMs.takeIf { it > 0 } ?: 1L
|
||||
@@ -61,7 +59,6 @@ fun PlayerSeekBar(
|
||||
onValueChange = { newValue ->
|
||||
if (!isScrubbing) {
|
||||
isScrubbing = true
|
||||
onScrubStarted()
|
||||
}
|
||||
sliderPosition = newValue
|
||||
},
|
||||
@@ -69,7 +66,6 @@ fun PlayerSeekBar(
|
||||
val targetPosition = sliderPosition.toLong().coerceIn(0L, safeDuration)
|
||||
isScrubbing = false
|
||||
onSeek(targetPosition)
|
||||
onScrubFinished()
|
||||
},
|
||||
valueRange = 0f..safeDuration.toFloat(),
|
||||
colors = SliderDefaults.colors(
|
||||
|
||||
@@ -1,42 +1,55 @@
|
||||
package hu.bbara.purefin.ui.screen.series
|
||||
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import android.content.Intent
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Download
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import hu.bbara.purefin.core.download.DownloadState
|
||||
import hu.bbara.purefin.core.feature.content.series.SeriesViewModel
|
||||
import hu.bbara.purefin.core.image.ArtworkKind
|
||||
import hu.bbara.purefin.core.image.ImageUrlBuilder
|
||||
import hu.bbara.purefin.core.navigation.EpisodeDto
|
||||
import hu.bbara.purefin.core.navigation.Route
|
||||
import hu.bbara.purefin.core.navigation.SeriesDto
|
||||
import hu.bbara.purefin.model.CastMember
|
||||
import hu.bbara.purefin.model.Episode
|
||||
import hu.bbara.purefin.model.Season
|
||||
import hu.bbara.purefin.model.Series
|
||||
import hu.bbara.purefin.navigation.LocalNavigationManager
|
||||
import hu.bbara.purefin.player.PlayerActivity
|
||||
import hu.bbara.purefin.ui.common.media.MediaDetailActionsUiModel
|
||||
import hu.bbara.purefin.ui.common.media.MediaDetailCastUiModel
|
||||
import hu.bbara.purefin.ui.common.media.MediaDetailPrimaryActionUiModel
|
||||
import hu.bbara.purefin.ui.common.media.MediaDetailScaffold
|
||||
import hu.bbara.purefin.ui.common.media.MediaSynopsis
|
||||
import hu.bbara.purefin.ui.screen.series.components.CastRow
|
||||
import hu.bbara.purefin.ui.common.media.MediaDetailScaffoldUiModel
|
||||
import hu.bbara.purefin.ui.common.media.MediaDetailSecondaryActionUiModel
|
||||
import hu.bbara.purefin.ui.common.media.MediaDetailSynopsisUiModel
|
||||
import hu.bbara.purefin.ui.common.media.mediaPlayButtonText
|
||||
import hu.bbara.purefin.ui.common.media.mediaPlaybackProgress
|
||||
import hu.bbara.purefin.ui.common.permission.rememberNotificationPermissionGate
|
||||
import hu.bbara.purefin.ui.screen.series.components.DownloadOptionsBottomSheet
|
||||
import hu.bbara.purefin.ui.screen.series.components.EpisodeCarousel
|
||||
import hu.bbara.purefin.ui.screen.series.components.SeasonTabs
|
||||
import hu.bbara.purefin.ui.screen.series.components.SeriesActionButtons
|
||||
import hu.bbara.purefin.ui.screen.series.components.SeriesDownloadButtonTag
|
||||
import hu.bbara.purefin.ui.screen.series.components.SeriesDownloadOption
|
||||
import hu.bbara.purefin.ui.screen.series.components.SeriesMetaChips
|
||||
import hu.bbara.purefin.ui.screen.series.components.SeriesPlayButtonTag
|
||||
import hu.bbara.purefin.ui.screen.series.components.SeriesTopBar
|
||||
import hu.bbara.purefin.ui.screen.series.components.SeriesWatchedButtonTag
|
||||
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
|
||||
import hu.bbara.purefin.ui.theme.AppTheme
|
||||
import java.util.UUID
|
||||
|
||||
@Composable
|
||||
@@ -50,35 +63,60 @@ fun SeriesScreen(
|
||||
}
|
||||
|
||||
val seriesState = viewModel.series.collectAsStateWithLifecycle()
|
||||
val requestNotificationPermission = rememberNotificationPermissionGate()
|
||||
|
||||
val seriesData = seriesState.value
|
||||
if (seriesData != null && seriesData.seasons.isNotEmpty()) {
|
||||
if (seriesData != null) {
|
||||
LaunchedEffect(seriesData) {
|
||||
viewModel.observeSeriesDownloadState(seriesData)
|
||||
}
|
||||
val seriesDownloadState = viewModel.seriesDownloadState.collectAsStateWithLifecycle().value
|
||||
val seasonDownloadState = viewModel.seasonDownloadState.collectAsStateWithLifecycle().value
|
||||
val isSmartDownloadEnabled = viewModel.isSmartDownloadEnabled.collectAsStateWithLifecycle().value
|
||||
|
||||
fun canPerformDownloadOption(option: SeriesDownloadOption): Boolean =
|
||||
option.canPerform(
|
||||
seriesDownloadState = seriesDownloadState,
|
||||
seasonDownloadState = seasonDownloadState,
|
||||
isSmartDownloadEnabled = isSmartDownloadEnabled
|
||||
)
|
||||
|
||||
fun performDownloadOption(option: SeriesDownloadOption, selectedSeason: Season) {
|
||||
when (option) {
|
||||
SeriesDownloadOption.SEASON ->
|
||||
viewModel.downloadSeason(seriesData.id, selectedSeason.id)
|
||||
|
||||
SeriesDownloadOption.SERIES ->
|
||||
viewModel.downloadSeries(seriesData)
|
||||
|
||||
SeriesDownloadOption.SMART ->
|
||||
viewModel.enableSmartDownload(seriesData.id)
|
||||
|
||||
SeriesDownloadOption.DELETE_SMART ->
|
||||
viewModel.deleteSmartDownloads(seriesData.id)
|
||||
}
|
||||
}
|
||||
|
||||
SeriesScreenInternal(
|
||||
series = seriesData,
|
||||
seriesDownloadState = viewModel.seriesDownloadState.collectAsStateWithLifecycle().value,
|
||||
seasonDownloadState = viewModel.seasonDownloadState.collectAsStateWithLifecycle().value,
|
||||
isSmartDownloadEnabled = viewModel.isSmartDownloadEnabled.collectAsStateWithLifecycle().value,
|
||||
seriesDownloadState = seriesDownloadState,
|
||||
seasonDownloadState = seasonDownloadState,
|
||||
isSmartDownloadEnabled = isSmartDownloadEnabled,
|
||||
onDownloadOptionSelected = { option, selectedSeason ->
|
||||
when (option) {
|
||||
SeriesDownloadOption.SEASON ->
|
||||
viewModel.downloadSeason(seriesData.id, selectedSeason.id)
|
||||
|
||||
SeriesDownloadOption.SERIES ->
|
||||
viewModel.downloadSeries(seriesData)
|
||||
|
||||
SeriesDownloadOption.SMART ->
|
||||
viewModel.enableSmartDownload(seriesData.id)
|
||||
|
||||
SeriesDownloadOption.DELETE_SMART ->
|
||||
viewModel.deleteSmartDownloads(seriesData.id)
|
||||
if (canPerformDownloadOption(option)) {
|
||||
if (option.requiresNotificationPermission()) {
|
||||
requestNotificationPermission {
|
||||
performDownloadOption(option, selectedSeason)
|
||||
}
|
||||
} else {
|
||||
performDownloadOption(option, selectedSeason)
|
||||
}
|
||||
}
|
||||
},
|
||||
onLoadSeasonEpisodes = viewModel::loadSeasonEpisodes,
|
||||
onObserveSeasonDownloadState = viewModel::observeSeasonDownloadState,
|
||||
onBack = viewModel::onGoHome,
|
||||
onMarkAsWatched = viewModel::markAsWatched,
|
||||
offline = series.offline,
|
||||
modifier = modifier
|
||||
)
|
||||
@@ -87,6 +125,7 @@ fun SeriesScreen(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun SeriesScreenInternal(
|
||||
series: Series,
|
||||
@@ -97,211 +136,174 @@ private fun SeriesScreenInternal(
|
||||
onLoadSeasonEpisodes: (UUID, UUID) -> Unit,
|
||||
onObserveSeasonDownloadState: (List<Episode>) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
onMarkAsWatched: (Boolean) -> Unit = {},
|
||||
offline: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val context = LocalContext.current
|
||||
val navigationManager = LocalNavigationManager.current
|
||||
var showDownloadDialog by remember { mutableStateOf(false) }
|
||||
|
||||
fun getDefaultSeason(): Season {
|
||||
return series.seasons.firstOrNull { it.unwatchedEpisodeCount > 0 } ?: series.seasons.first()
|
||||
fun getDefaultSeason(): Season? {
|
||||
return series.seasons.firstOrNull { it.unwatchedEpisodeCount > 0 } ?: series.seasons.firstOrNull()
|
||||
}
|
||||
|
||||
var selectedSeasonId by remember(series.id) { mutableStateOf(getDefaultSeason().id) }
|
||||
var selectedSeasonId by remember(series.id) { mutableStateOf(getDefaultSeason()?.id) }
|
||||
val selectedSeason =
|
||||
series.seasons.firstOrNull { it.id == selectedSeasonId } ?: getDefaultSeason()
|
||||
val nextUpEpisode = selectedSeason.episodes.firstOrNull { !it.watched }
|
||||
?: selectedSeason.episodes.firstOrNull()
|
||||
|
||||
LaunchedEffect(series.id, selectedSeason.id) {
|
||||
onLoadSeasonEpisodes(series.id, selectedSeason.id)
|
||||
val nextUpEpisode = selectedSeason?.episodes?.firstOrNull { !it.watched }
|
||||
?: selectedSeason?.episodes?.firstOrNull()
|
||||
val playAction = remember(nextUpEpisode, offline) {
|
||||
nextUpEpisode?.let { episode ->
|
||||
{
|
||||
navigationManager.navigate(
|
||||
Route.EpisodeRoute(
|
||||
EpisodeDto(
|
||||
id = episode.id,
|
||||
seasonId = episode.seasonId,
|
||||
seriesId = episode.seriesId,
|
||||
offline = offline,
|
||||
)
|
||||
)
|
||||
)
|
||||
val intent = Intent(context, PlayerActivity::class.java)
|
||||
intent.putExtra("MEDIA_ID", episode.id.toString())
|
||||
context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(selectedSeason.id, selectedSeason.episodes) {
|
||||
onObserveSeasonDownloadState(selectedSeason.episodes)
|
||||
LaunchedEffect(series.id, selectedSeason?.id) {
|
||||
selectedSeason?.let { onLoadSeasonEpisodes(series.id, it.id) }
|
||||
}
|
||||
|
||||
LaunchedEffect(selectedSeason?.id, selectedSeason?.episodes) {
|
||||
selectedSeason?.let { onObserveSeasonDownloadState(it.episodes) }
|
||||
}
|
||||
|
||||
MediaDetailScaffold(
|
||||
imageUrl = ImageUrlBuilder.finishImageUrl(series.imageUrlPrefix, ArtworkKind.PRIMARY),
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
SeriesTopBar(onBack = onBack)
|
||||
},
|
||||
heroContent = {
|
||||
SeriesHeroContent(series = series)
|
||||
}
|
||||
) {
|
||||
SeriesActionButtons(
|
||||
nextUpEpisode = nextUpEpisode,
|
||||
seriesDownloadState = seriesDownloadState,
|
||||
uiModel = series.toMediaDetailScaffoldUiModel(
|
||||
selectedSeason = selectedSeason,
|
||||
nextUpEpisode = nextUpEpisode,
|
||||
onPlayClick = playAction ?: {},
|
||||
onDownloadClick = { showDownloadDialog = true },
|
||||
onMarkAsWatched = onMarkAsWatched,
|
||||
bodyColor = scheme.onSurface
|
||||
),
|
||||
modifier = modifier,
|
||||
topBar = { scrollBehavior ->
|
||||
SeriesTopBar(
|
||||
onBack = onBack,
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
},
|
||||
) { _modifier ->
|
||||
if (selectedSeason != null) {
|
||||
SeasonTabs(
|
||||
seasons = series.seasons,
|
||||
selectedSeason = selectedSeason,
|
||||
onSelect = { selectedSeasonId = it.id },
|
||||
modifier = _modifier
|
||||
)
|
||||
EpisodeCarousel(
|
||||
episodes = selectedSeason.episodes,
|
||||
modifier = _modifier
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = "Loading seasons...",
|
||||
color = scheme.onSurfaceVariant,
|
||||
fontSize = 13.sp,
|
||||
modifier = _modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showDownloadDialog && selectedSeason != null) {
|
||||
DownloadOptionsBottomSheet(
|
||||
selectedSeasonName = selectedSeason.name,
|
||||
seriesDownloadState = seriesDownloadState,
|
||||
seasonDownloadState = seasonDownloadState,
|
||||
isSmartDownloadEnabled = isSmartDownloadEnabled,
|
||||
offline = offline,
|
||||
onDownloadOptionSelected = { option ->
|
||||
onDownloadOptionSelected(option, selectedSeason)
|
||||
}
|
||||
)
|
||||
MediaSynopsis(
|
||||
synopsis = series.synopsis,
|
||||
bodyColor = scheme.onSurface,
|
||||
bodyFontSize = 13.sp,
|
||||
bodyLineHeight = null,
|
||||
titleSpacing = 8.dp
|
||||
)
|
||||
SeasonTabs(
|
||||
seasons = series.seasons,
|
||||
selectedSeason = selectedSeason,
|
||||
onSelect = { selectedSeasonId = it.id }
|
||||
)
|
||||
EpisodeCarousel(
|
||||
episodes = selectedSeason.episodes,
|
||||
)
|
||||
if (series.cast.isNotEmpty()) {
|
||||
Text(
|
||||
text = "Cast",
|
||||
color = scheme.onBackground,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
CastRow(cast = series.cast)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SeriesHeroContent(
|
||||
series: Series,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
|
||||
Text(
|
||||
text = series.name,
|
||||
color = scheme.onBackground,
|
||||
fontSize = 30.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
lineHeight = 36.sp
|
||||
)
|
||||
SeriesMetaChips(series = series)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun SeriesScreenPreview() {
|
||||
AppTheme {
|
||||
SeriesScreenInternal(
|
||||
series = previewSeries(),
|
||||
seriesDownloadState = DownloadState.Downloading(progressPercent = 0.58f),
|
||||
seasonDownloadState = DownloadState.NotDownloaded,
|
||||
isSmartDownloadEnabled = true,
|
||||
onDownloadOptionSelected = { _, _ -> },
|
||||
onLoadSeasonEpisodes = { _, _ -> },
|
||||
onObserveSeasonDownloadState = {},
|
||||
onBack = {},
|
||||
offline = false,
|
||||
onDownloadOptionSelected = {
|
||||
showDownloadDialog = false
|
||||
onDownloadOptionSelected(it, selectedSeason)
|
||||
},
|
||||
onDismiss = { showDownloadDialog = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun previewSeries(): Series {
|
||||
val libraryId = UUID.fromString("66666666-6666-6666-6666-666666666666")
|
||||
val seriesId = UUID.fromString("77777777-7777-7777-7777-777777777777")
|
||||
val seasonOneId = UUID.fromString("88888888-8888-8888-8888-888888888888")
|
||||
val seasonTwoId = UUID.fromString("99999999-9999-9999-9999-999999999999")
|
||||
|
||||
val seasonOneEpisodes = listOf(
|
||||
Episode(
|
||||
id = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1"),
|
||||
seriesId = seriesId,
|
||||
seriesName = "Constellation",
|
||||
seasonId = seasonOneId,
|
||||
seasonIndex = 1,
|
||||
index = 1,
|
||||
title = "A Fresh Start",
|
||||
synopsis = "A fractured crew tries to reassemble after a year apart.",
|
||||
releaseDate = "2024",
|
||||
rating = "16+",
|
||||
runtime = "51m",
|
||||
progress = 100.0,
|
||||
watched = true,
|
||||
format = "4K",
|
||||
imageUrlPrefix = "https://images.unsplash.com/photo-1497032205916-ac775f0649ae",
|
||||
cast = emptyList()
|
||||
),
|
||||
Episode(
|
||||
id = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa2"),
|
||||
seriesId = seriesId,
|
||||
seriesName = "Constellation",
|
||||
seasonId = seasonOneId,
|
||||
seasonIndex = 1,
|
||||
index = 2,
|
||||
title = "Signals",
|
||||
synopsis = "Anomalies around the station point to a cover-up.",
|
||||
releaseDate = "2024",
|
||||
rating = "16+",
|
||||
runtime = "48m",
|
||||
progress = 34.0,
|
||||
watched = false,
|
||||
format = "4K",
|
||||
imageUrlPrefix = "https://images.unsplash.com/photo-1520034475321-cbe63696469a",
|
||||
cast = emptyList()
|
||||
)
|
||||
)
|
||||
val seasonTwoEpisodes = listOf(
|
||||
Episode(
|
||||
id = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa3"),
|
||||
seriesId = seriesId,
|
||||
seriesName = "Constellation",
|
||||
seasonId = seasonTwoId,
|
||||
seasonIndex = 2,
|
||||
index = 1,
|
||||
title = "Return Window",
|
||||
synopsis = "A high-risk jump changes the rules of the mission.",
|
||||
releaseDate = "2025",
|
||||
rating = "16+",
|
||||
runtime = "54m",
|
||||
progress = null,
|
||||
watched = false,
|
||||
format = "4K",
|
||||
imageUrlPrefix = "https://images.unsplash.com/photo-1500534314209-a25ddb2bd429",
|
||||
cast = emptyList()
|
||||
)
|
||||
)
|
||||
|
||||
return Series(
|
||||
id = seriesId,
|
||||
libraryId = libraryId,
|
||||
name = "Constellation",
|
||||
synopsis = "When an experiment in orbit goes wrong, the survivors return home to a world that no longer fits their memories.",
|
||||
year = "2024",
|
||||
imageUrlPrefix = "https://images.unsplash.com/photo-1446776811953-b23d57bd21aa",
|
||||
unwatchedEpisodeCount = 2,
|
||||
seasonCount = 2,
|
||||
seasons = listOf(
|
||||
Season(
|
||||
id = seasonOneId,
|
||||
seriesId = seriesId,
|
||||
name = "Season 1",
|
||||
index = 1,
|
||||
unwatchedEpisodeCount = 1,
|
||||
episodeCount = seasonOneEpisodes.size,
|
||||
episodes = seasonOneEpisodes
|
||||
private fun Series.toMediaDetailScaffoldUiModel(
|
||||
selectedSeason: Season?,
|
||||
nextUpEpisode: Episode?,
|
||||
onPlayClick: () -> Unit,
|
||||
onDownloadClick: () -> Unit,
|
||||
onMarkAsWatched: (Boolean) -> Unit,
|
||||
bodyColor: Color,
|
||||
): MediaDetailScaffoldUiModel = MediaDetailScaffoldUiModel(
|
||||
imageUrl = ImageUrlBuilder.finishImageUrl(imageUrlPrefix, ArtworkKind.PRIMARY),
|
||||
title = name,
|
||||
titleFontSize = 30.sp,
|
||||
titleLineHeight = 36.sp,
|
||||
metadataItems = listOf(year, "$seasonCount Seasons"),
|
||||
actions = selectedSeason?.let {
|
||||
val seriesWatched = unwatchedEpisodeCount == 0
|
||||
MediaDetailActionsUiModel(
|
||||
primaryAction = MediaDetailPrimaryActionUiModel(
|
||||
text = mediaPlayButtonText(nextUpEpisode?.progress, nextUpEpisode?.watched),
|
||||
progress = mediaPlaybackProgress(nextUpEpisode?.progress),
|
||||
onClick = onPlayClick,
|
||||
testTag = SeriesPlayButtonTag
|
||||
),
|
||||
Season(
|
||||
id = seasonTwoId,
|
||||
seriesId = seriesId,
|
||||
name = "Season 2",
|
||||
index = 2,
|
||||
unwatchedEpisodeCount = 1,
|
||||
episodeCount = seasonTwoEpisodes.size,
|
||||
episodes = seasonTwoEpisodes
|
||||
secondaryActions = listOf(
|
||||
MediaDetailSecondaryActionUiModel.MarkAsWatched(
|
||||
watched = seriesWatched,
|
||||
onClick = { onMarkAsWatched(!seriesWatched) },
|
||||
testTag = SeriesWatchedButtonTag
|
||||
),
|
||||
MediaDetailSecondaryActionUiModel.Icon(
|
||||
icon = Icons.Outlined.Download,
|
||||
onClick = onDownloadClick,
|
||||
testTag = SeriesDownloadButtonTag
|
||||
)
|
||||
)
|
||||
),
|
||||
cast = listOf(
|
||||
CastMember("Noomi Rapace", "Jo", null),
|
||||
CastMember("Jonathan Banks", "Henry", null),
|
||||
CastMember("James D'Arcy", "Magnus", null)
|
||||
)
|
||||
)
|
||||
},
|
||||
synopsis = MediaDetailSynopsisUiModel(
|
||||
text = synopsis,
|
||||
bodyColor = bodyColor,
|
||||
bodyFontSize = 13.sp,
|
||||
bodyLineHeight = null,
|
||||
titleSpacing = 8.dp
|
||||
),
|
||||
cast = if (cast.isNotEmpty()) {
|
||||
MediaDetailCastUiModel(
|
||||
members = cast,
|
||||
cardWidth = 84.dp,
|
||||
nameFontSize = 11.sp,
|
||||
roleFontSize = 10.sp
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
)
|
||||
|
||||
private fun SeriesDownloadOption.canPerform(
|
||||
seriesDownloadState: DownloadState,
|
||||
seasonDownloadState: DownloadState,
|
||||
isSmartDownloadEnabled: Boolean,
|
||||
): Boolean = when (this) {
|
||||
SeriesDownloadOption.SEASON -> seasonDownloadState is DownloadState.NotDownloaded
|
||||
SeriesDownloadOption.SERIES -> seriesDownloadState is DownloadState.NotDownloaded
|
||||
SeriesDownloadOption.SMART -> !isSmartDownloadEnabled
|
||||
SeriesDownloadOption.DELETE_SMART -> true
|
||||
}
|
||||
|
||||
private fun SeriesDownloadOption.requiresNotificationPermission(): Boolean = when (this) {
|
||||
SeriesDownloadOption.SEASON,
|
||||
SeriesDownloadOption.SERIES,
|
||||
SeriesDownloadOption.SMART -> true
|
||||
SeriesDownloadOption.DELETE_SMART -> false
|
||||
}
|
||||
|
||||
@@ -1,45 +1,38 @@
|
||||
package hu.bbara.purefin.ui.screen.series.components
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.sizeIn
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Add
|
||||
import androidx.compose.material.icons.outlined.ArrowBack
|
||||
import androidx.compose.material.icons.outlined.ArrowDropDown
|
||||
import androidx.compose.material.icons.outlined.AutoAwesome
|
||||
import androidx.compose.material.icons.outlined.Cast
|
||||
import androidx.compose.material.icons.outlined.Close
|
||||
import androidx.compose.material.icons.outlined.Check
|
||||
import androidx.compose.material.icons.outlined.Delete
|
||||
import androidx.compose.material.icons.outlined.Download
|
||||
import androidx.compose.material.icons.outlined.DownloadDone
|
||||
import androidx.compose.material.icons.outlined.MoreVert
|
||||
import androidx.compose.material.icons.outlined.PlayCircle
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -48,8 +41,8 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -60,29 +53,21 @@ import hu.bbara.purefin.core.download.DownloadState
|
||||
import hu.bbara.purefin.core.feature.content.series.SeriesViewModel
|
||||
import hu.bbara.purefin.core.image.ArtworkKind
|
||||
import hu.bbara.purefin.core.image.ImageUrlBuilder
|
||||
import hu.bbara.purefin.core.navigation.EpisodeDto
|
||||
import hu.bbara.purefin.core.navigation.Route
|
||||
import hu.bbara.purefin.model.CastMember
|
||||
import hu.bbara.purefin.model.Episode
|
||||
import hu.bbara.purefin.model.Season
|
||||
import hu.bbara.purefin.model.Series
|
||||
import hu.bbara.purefin.navigation.LocalNavigationManager
|
||||
import hu.bbara.purefin.player.PlayerActivity
|
||||
import hu.bbara.purefin.ui.common.badge.WatchStateBadge
|
||||
import hu.bbara.purefin.ui.common.bar.MediaProgressBar
|
||||
import hu.bbara.purefin.ui.common.button.GhostIconButton
|
||||
import hu.bbara.purefin.ui.common.button.MediaActionButton
|
||||
import hu.bbara.purefin.ui.common.button.MediaResumeButton
|
||||
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
|
||||
import hu.bbara.purefin.ui.common.media.MediaMetadataFlowRow
|
||||
import hu.bbara.purefin.ui.common.media.mediaPlayButtonText
|
||||
import hu.bbara.purefin.ui.common.media.mediaPlaybackProgress
|
||||
import hu.bbara.purefin.ui.screen.home.components.DefaultTopBar
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun SeriesTopBar(
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
scrollBehavior: TopAppBarScrollBehavior? = null
|
||||
) {
|
||||
DefaultTopBar(
|
||||
leftActions = {
|
||||
@@ -97,97 +82,11 @@ internal fun SeriesTopBar(
|
||||
GhostIconButton(icon = Icons.Outlined.MoreVert, contentDescription = "More", onClick = { })
|
||||
}
|
||||
},
|
||||
withIcon = false
|
||||
withIcon = false,
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun SeriesMetaChips(series: Series) {
|
||||
MediaMetadataFlowRow(
|
||||
items = listOf(series.year, "${series.seasonCount} Seasons")
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun SeriesActionButtons(
|
||||
nextUpEpisode: Episode?,
|
||||
seriesDownloadState: DownloadState,
|
||||
selectedSeason: Season,
|
||||
seasonDownloadState: DownloadState,
|
||||
isSmartDownloadEnabled: Boolean,
|
||||
offline: Boolean,
|
||||
onDownloadOptionSelected: (SeriesDownloadOption) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val navigationManager = LocalNavigationManager.current
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
var showDownloadDialog by remember { mutableStateOf(false) }
|
||||
val playAction = remember(nextUpEpisode, offline) {
|
||||
nextUpEpisode?.let { episode ->
|
||||
{
|
||||
navigationManager.navigate(
|
||||
Route.EpisodeRoute(
|
||||
EpisodeDto(
|
||||
id = episode.id,
|
||||
seasonId = episode.seasonId,
|
||||
seriesId = episode.seriesId,
|
||||
offline = offline,
|
||||
)
|
||||
)
|
||||
)
|
||||
val intent = Intent(context, PlayerActivity::class.java)
|
||||
intent.putExtra("MEDIA_ID", episode.id.toString())
|
||||
context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
MediaResumeButton(
|
||||
text = mediaPlayButtonText(nextUpEpisode?.progress, nextUpEpisode?.watched),
|
||||
progress = mediaPlaybackProgress(nextUpEpisode?.progress),
|
||||
onClick = playAction ?: {},
|
||||
modifier = Modifier
|
||||
.sizeIn(maxWidth = 200.dp)
|
||||
.testTag(SeriesPlayButtonTag)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
MediaActionButton(
|
||||
backgroundColor = MaterialTheme.colorScheme.surface,
|
||||
iconColor = MaterialTheme.colorScheme.onSurface,
|
||||
icon = Icons.Outlined.Add,
|
||||
height = 48.dp,
|
||||
modifier = Modifier.testTag(SeriesAddButtonTag)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
MediaActionButton(
|
||||
backgroundColor = MaterialTheme.colorScheme.surface,
|
||||
iconColor = MaterialTheme.colorScheme.onSurface,
|
||||
icon = when {
|
||||
seriesDownloadState is DownloadState.Downloading -> Icons.Outlined.Close
|
||||
seriesDownloadState is DownloadState.Downloaded -> Icons.Outlined.DownloadDone
|
||||
else -> Icons.Outlined.Download
|
||||
},
|
||||
height = 48.dp,
|
||||
modifier = Modifier.testTag(SeriesDownloadButtonTag),
|
||||
onClick = { showDownloadDialog = true }
|
||||
)
|
||||
}
|
||||
|
||||
if (showDownloadDialog) {
|
||||
DownloadOptionsDialog(
|
||||
selectedSeasonName = selectedSeason.name,
|
||||
seasonDownloadState = seasonDownloadState,
|
||||
isSmartDownloadEnabled = isSmartDownloadEnabled,
|
||||
onDownloadOptionSelected = {
|
||||
showDownloadDialog = false
|
||||
onDownloadOptionSelected(it)
|
||||
},
|
||||
onDismiss = { showDownloadDialog = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal enum class SeriesDownloadOption {
|
||||
SEASON,
|
||||
SERIES,
|
||||
@@ -195,71 +94,140 @@ internal enum class SeriesDownloadOption {
|
||||
DELETE_SMART
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun DownloadOptionsDialog(
|
||||
internal fun DownloadOptionsBottomSheet(
|
||||
selectedSeasonName: String,
|
||||
seriesDownloadState: DownloadState,
|
||||
seasonDownloadState: DownloadState,
|
||||
isSmartDownloadEnabled: Boolean,
|
||||
onDownloadOptionSelected: (SeriesDownloadOption) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
modifier = Modifier.testTag(SeriesDownloadDialogTag),
|
||||
title = { Text("Download") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 24.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Download options",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = "Choose how to download this series.",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
TextButton(
|
||||
onClick = { onDownloadOptionSelected(SeriesDownloadOption.SEASON) },
|
||||
modifier = Modifier.testTag(SeriesDownloadSeasonButtonTag)
|
||||
) {
|
||||
Text(
|
||||
when (seasonDownloadState) {
|
||||
is DownloadState.Downloaded -> "$selectedSeasonName Downloaded"
|
||||
is DownloadState.Downloading -> "Downloading $selectedSeasonName"
|
||||
else -> "Download $selectedSeasonName"
|
||||
}
|
||||
)
|
||||
}
|
||||
TextButton(
|
||||
onClick = { onDownloadOptionSelected(SeriesDownloadOption.SERIES) },
|
||||
modifier = Modifier.testTag(SeriesDownloadAllButtonTag)
|
||||
) {
|
||||
Text("Download All")
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
|
||||
DownloadOptionRow(
|
||||
title = "Download selected season",
|
||||
supportingText = when (seasonDownloadState) {
|
||||
is DownloadState.Downloaded -> "$selectedSeasonName is already downloaded."
|
||||
is DownloadState.Downloading -> "$selectedSeasonName is downloading."
|
||||
else -> "Save episodes from $selectedSeasonName for offline viewing."
|
||||
},
|
||||
icon = Icons.Outlined.Download,
|
||||
onClick = { onDownloadOptionSelected(SeriesDownloadOption.SEASON) },
|
||||
enabled = seasonDownloadState is DownloadState.NotDownloaded,
|
||||
modifier = Modifier.testTag(SeriesDownloadSeasonButtonTag)
|
||||
)
|
||||
DownloadOptionRow(
|
||||
title = "Download all episodes",
|
||||
supportingText = when (seriesDownloadState) {
|
||||
is DownloadState.Downloaded -> "All episodes in this series are already downloaded."
|
||||
is DownloadState.Downloading -> "All episodes in this series are downloading."
|
||||
else -> "Save every available episode in this series."
|
||||
},
|
||||
icon = Icons.Outlined.Download,
|
||||
onClick = { onDownloadOptionSelected(SeriesDownloadOption.SERIES) },
|
||||
enabled = seriesDownloadState is DownloadState.NotDownloaded,
|
||||
modifier = Modifier.testTag(SeriesDownloadAllButtonTag)
|
||||
)
|
||||
if (isSmartDownloadEnabled) {
|
||||
TextButton(
|
||||
DownloadOptionRow(
|
||||
title = "Delete series downloads",
|
||||
supportingText = "Turns off Smart Downloads and removes offline episodes for this series.",
|
||||
icon = Icons.Outlined.Delete,
|
||||
onClick = { onDownloadOptionSelected(SeriesDownloadOption.DELETE_SMART) },
|
||||
modifier = Modifier.testTag(SeriesSmartDownloadButtonTag),
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error
|
||||
)
|
||||
) {
|
||||
Text("Delete Smart Downloads")
|
||||
}
|
||||
destructive = true
|
||||
)
|
||||
} else {
|
||||
TextButton(
|
||||
DownloadOptionRow(
|
||||
title = "Smart Downloads",
|
||||
supportingText = "Automatically keep the next unwatched episodes available offline.",
|
||||
icon = Icons.Outlined.AutoAwesome,
|
||||
onClick = { onDownloadOptionSelected(SeriesDownloadOption.SMART) },
|
||||
modifier = Modifier.testTag(SeriesSmartDownloadButtonTag)
|
||||
) {
|
||||
Text("Smart Download")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DownloadOptionRow(
|
||||
title: String,
|
||||
supportingText: String,
|
||||
icon: ImageVector,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
destructive: Boolean = false,
|
||||
enabled: Boolean = true,
|
||||
) {
|
||||
val contentColor = if (!enabled) {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
} else if (destructive) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
}
|
||||
val supportingColor = if (!enabled) {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
} else if (destructive) {
|
||||
MaterialTheme.colorScheme.error.copy(alpha = 0.8f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = contentColor
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
Text(
|
||||
text = supportingText,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = supportingColor
|
||||
)
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = contentColor
|
||||
)
|
||||
},
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
modifier = modifier.clickable(enabled = enabled, onClick = onClick)
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun SeasonTabs(
|
||||
seasons: List<Season>,
|
||||
@@ -267,74 +235,81 @@ internal fun SeasonTabs(
|
||||
modifier: Modifier = Modifier,
|
||||
onSelect: (Season) -> Unit
|
||||
) {
|
||||
var showBottomSheet by remember { mutableStateOf(false) }
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(20.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
seasons.forEachIndexed { index, season ->
|
||||
SeasonTab(
|
||||
name = season.name,
|
||||
isSelected = season == selectedSeason,
|
||||
modifier = Modifier
|
||||
.testTag("$SeriesSeasonTabTagPrefix$index")
|
||||
.clickable { onSelect(season) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SeasonTab(
|
||||
name: String,
|
||||
isSelected: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val mutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.7f)
|
||||
val color = if (isSelected) scheme.primary else mutedStrong
|
||||
val borderColor = if (isSelected) scheme.primary else Color.Transparent
|
||||
Column(
|
||||
modifier = modifier
|
||||
.padding(bottom = 8.dp)
|
||||
.clickable { showBottomSheet = true }
|
||||
.testTag(SeriesSeasonSelectorTag),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = name,
|
||||
color = color,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium
|
||||
text = selectedSeason?.name ?: "Select Season",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = scheme.onSurface
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.height(2.dp)
|
||||
.width(52.dp)
|
||||
.background(borderColor)
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.ArrowDropDown,
|
||||
contentDescription = "Select season",
|
||||
tint = scheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
if (showBottomSheet) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { showBottomSheet = false },
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 24.dp)
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
text = "Select Season",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = scheme.onSurface,
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
items(seasons) { season ->
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(text = season.name)
|
||||
},
|
||||
leadingContent = if (season == selectedSeason) {
|
||||
{
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Check,
|
||||
contentDescription = null,
|
||||
tint = scheme.primary
|
||||
)
|
||||
}
|
||||
} else null,
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
modifier = Modifier.clickable {
|
||||
onSelect(season)
|
||||
showBottomSheet = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun EpisodeCarousel(episodes: List<Episode>, modifier: Modifier = Modifier) {
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
LaunchedEffect(episodes) {
|
||||
val firstUnwatchedIndex = episodes.indexOfFirst { !it.watched }.let { if (it == -1) 0 else it }
|
||||
if (firstUnwatchedIndex != 0) {
|
||||
listState.animateScrollToItem(firstUnwatchedIndex)
|
||||
} else {
|
||||
listState.scrollToItem(0)
|
||||
}
|
||||
}
|
||||
|
||||
LazyRow(
|
||||
state = listState,
|
||||
modifier = modifier.testTag(SeriesEpisodeCarouselTag),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(SeriesEpisodeCarouselTag),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
items(episodes, key = { episode -> episode.id }) { episode ->
|
||||
episodes.forEach { episode ->
|
||||
EpisodeCard(episode = episode)
|
||||
}
|
||||
}
|
||||
@@ -347,20 +322,21 @@ private fun EpisodeCard(
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val mutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.7f)
|
||||
Column(
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.width(260.dp)
|
||||
.fillMaxWidth()
|
||||
.testTag("$SeriesEpisodeCardTagPrefix${episode.id}")
|
||||
.clickable { viewModel.onSelectEpisode(
|
||||
seriesId = episode.seriesId,
|
||||
seasonId = episode.seasonId,
|
||||
episodeId = episode.id
|
||||
) },
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.width(132.dp)
|
||||
.aspectRatio(16f / 9f)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(scheme.surface)
|
||||
@@ -377,28 +353,6 @@ private fun EpisodeCard(
|
||||
.matchParentSize()
|
||||
.background(scheme.background.copy(alpha = 0.2f))
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.PlayCircle,
|
||||
contentDescription = null,
|
||||
tint = scheme.onBackground,
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.size(32.dp)
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(6.dp)
|
||||
.background(scheme.background.copy(alpha = 0.8f), RoundedCornerShape(6.dp))
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = episode.runtime,
|
||||
color = scheme.onBackground,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
if (episode.watched.not() && (episode.progress ?: 0.0) > 0) {
|
||||
MediaProgressBar(
|
||||
progress = (episode.progress ?: 0.0).toFloat().div(100),
|
||||
@@ -415,17 +369,19 @@ private fun EpisodeCard(
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = episode.title,
|
||||
color = scheme.onBackground,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = "Episode ${episode.index}",
|
||||
text = "Episode ${episode.index} • ${episode.runtime}",
|
||||
color = mutedStrong,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1,
|
||||
@@ -438,11 +394,12 @@ private fun EpisodeCard(
|
||||
internal const val SeriesPlayButtonTag = "series-play-button"
|
||||
internal const val SeriesAddButtonTag = "series-add-button"
|
||||
internal const val SeriesDownloadButtonTag = "series-download-button"
|
||||
internal const val SeriesWatchedButtonTag = "series-watched-button"
|
||||
internal const val SeriesDownloadDialogTag = "series-download-dialog"
|
||||
internal const val SeriesDownloadSeasonButtonTag = "series-download-season-button"
|
||||
internal const val SeriesDownloadAllButtonTag = "series-download-all-button"
|
||||
internal const val SeriesSmartDownloadButtonTag = "series-smart-download-button"
|
||||
internal const val SeriesSeasonTabTagPrefix = "series-season-tab-"
|
||||
internal const val SeriesSeasonSelectorTag = "series-season-selector"
|
||||
internal const val SeriesEpisodeCarouselTag = "series-episode-carousel"
|
||||
internal const val SeriesEpisodeCardTagPrefix = "series-episode-card-"
|
||||
|
||||
|
||||
@@ -13,16 +13,19 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
@@ -36,6 +39,7 @@ import hu.bbara.purefin.core.settings.StringSetting
|
||||
import hu.bbara.purefin.core.settings.VoidSetting
|
||||
import hu.bbara.purefin.ui.screen.home.components.DefaultTopBar
|
||||
import hu.bbara.purefin.ui.screen.home.components.DefaultTopBarIconButton
|
||||
import hu.bbara.purefin.ui.screen.home.components.rememberDefaultTopBarScrollBehavior
|
||||
import hu.bbara.purefin.ui.screen.settings.components.BooleanSettingItem
|
||||
import hu.bbara.purefin.ui.screen.settings.components.DropdownSettingItem
|
||||
import hu.bbara.purefin.ui.screen.settings.components.RangeSettingItem
|
||||
@@ -43,6 +47,7 @@ import hu.bbara.purefin.ui.screen.settings.components.ReadOnlySettingItem
|
||||
import hu.bbara.purefin.ui.screen.settings.components.StringSettingItem
|
||||
import hu.bbara.purefin.ui.screen.settings.components.VoidSettingItem
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
viewModel: SettingsViewModel = hiltViewModel(),
|
||||
@@ -50,6 +55,7 @@ fun SettingsScreen(
|
||||
) {
|
||||
val settingGroups by viewModel.settingGroups.collectAsStateWithLifecycle()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val topBarScrollBehavior = rememberDefaultTopBarScrollBehavior()
|
||||
|
||||
LaunchedEffect(viewModel) {
|
||||
viewModel.snackbarMessages.collect { message ->
|
||||
@@ -58,12 +64,17 @@ fun SettingsScreen(
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.nestedScroll(topBarScrollBehavior.nestedScrollConnection),
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
contentColor = MaterialTheme.colorScheme.onBackground,
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
SettingsTopBar(onBack = viewModel::onBack)
|
||||
SettingsTopBar(
|
||||
onBack = viewModel::onBack,
|
||||
scrollBehavior = topBarScrollBehavior
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
LazyColumn(
|
||||
@@ -77,7 +88,7 @@ fun SettingsScreen(
|
||||
item(key = "${groupIndex}-title") {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(
|
||||
start = 16.dp,
|
||||
@@ -224,11 +235,14 @@ private fun <T> DropdownSettingOptionItem(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun SettingsTopBar(
|
||||
onBack: () -> Unit
|
||||
onBack: () -> Unit,
|
||||
scrollBehavior: TopAppBarScrollBehavior? = null
|
||||
) {
|
||||
DefaultTopBar(
|
||||
scrollBehavior = scrollBehavior,
|
||||
leftActions = {
|
||||
DefaultTopBarIconButton(
|
||||
imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
|
||||
|
||||
@@ -2,7 +2,6 @@ package hu.bbara.purefin.ui.common.button
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.FilledIconButton
|
||||
@@ -22,7 +21,7 @@ import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
internal fun CircularIconButton(
|
||||
fun CircularIconButton(
|
||||
icon: ImageVector,
|
||||
contentDescription: String?,
|
||||
containerColor: Color,
|
||||
@@ -31,8 +30,6 @@ internal fun CircularIconButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
focusedScale: Float = 1f,
|
||||
focusedBorderWidth: Dp,
|
||||
focusedBorderColor: Color,
|
||||
focusedBackgroundColor: Color = containerColor
|
||||
) {
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
@@ -40,10 +37,6 @@ internal fun CircularIconButton(
|
||||
targetValue = if (isFocused) focusedScale else 1f,
|
||||
label = "scale"
|
||||
)
|
||||
val borderColor by animateColorAsState(
|
||||
targetValue = if (isFocused) focusedBorderColor else Color.Transparent,
|
||||
label = "border"
|
||||
)
|
||||
val backgroundColor by animateColorAsState(
|
||||
targetValue = if (isFocused) focusedBackgroundColor else containerColor,
|
||||
label = "background"
|
||||
@@ -60,11 +53,6 @@ internal fun CircularIconButton(
|
||||
modifier = modifier
|
||||
.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||
.size(size)
|
||||
.border(
|
||||
width = if (isFocused) focusedBorderWidth else focusedBorderWidth * 0,
|
||||
color = borderColor,
|
||||
shape = CircleShape
|
||||
)
|
||||
.onFocusChanged { isFocused = it.isFocused || it.hasFocus }
|
||||
) {
|
||||
Icon(
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package hu.bbara.purefin.ui.common.button
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
@Composable
|
||||
fun CircularTextButton(
|
||||
text: String,
|
||||
textColor: Color,
|
||||
fontSize: Int,
|
||||
containerColor: Color,
|
||||
size: Dp,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
focusedScale: Float = 1f,
|
||||
focusedBackgroundColor: Color = containerColor
|
||||
) {
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
val scale by animateFloatAsState(
|
||||
targetValue = if (isFocused) focusedScale else 1f,
|
||||
label = "scale"
|
||||
)
|
||||
val backgroundColor by animateColorAsState(
|
||||
targetValue = if (isFocused) focusedBackgroundColor else containerColor,
|
||||
label = "background"
|
||||
)
|
||||
|
||||
FilledTonalButton(
|
||||
onClick = onClick,
|
||||
colors = ButtonDefaults.filledTonalButtonColors(
|
||||
containerColor = backgroundColor,
|
||||
contentColor = textColor
|
||||
),
|
||||
shape = CircleShape,
|
||||
modifier = modifier
|
||||
.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||
.height(size)
|
||||
.onFocusChanged { isFocused = it.isFocused || it.hasFocus }
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
fontSize = fontSize.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package hu.bbara.purefin.ui.common.button
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Close
|
||||
import androidx.compose.material.icons.outlined.Download
|
||||
import androidx.compose.material.icons.outlined.DownloadDone
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import hu.bbara.purefin.core.download.DownloadState
|
||||
|
||||
@Composable
|
||||
fun DownloadActionButton(
|
||||
downloadState: DownloadState,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
height: Dp = 48.dp,
|
||||
) {
|
||||
MediaActionButton(
|
||||
backgroundColor = MaterialTheme.colorScheme.surface,
|
||||
iconColor = MaterialTheme.colorScheme.onSurface,
|
||||
icon = when (downloadState) {
|
||||
is DownloadState.NotDownloaded -> Icons.Outlined.Download
|
||||
is DownloadState.Downloading -> Icons.Outlined.Close
|
||||
is DownloadState.Downloaded -> Icons.Outlined.DownloadDone
|
||||
is DownloadState.Failed -> Icons.Outlined.Download
|
||||
},
|
||||
height = height,
|
||||
modifier = modifier,
|
||||
onClick = onClick
|
||||
)
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
@@ -15,8 +14,6 @@ fun GhostIconButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
focusedScale: Float = 1f,
|
||||
focusedBorderWidth: Dp = 0.dp,
|
||||
focusedBorderColor: Color = Color.Transparent,
|
||||
focusedBackgroundColor: Color? = null
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
@@ -31,7 +28,5 @@ fun GhostIconButton(
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
focusedScale = focusedScale,
|
||||
focusedBorderWidth = focusedBorderWidth,
|
||||
focusedBorderColor = focusedBorderColor
|
||||
)
|
||||
}
|
||||
|
||||
@@ -30,7 +30,5 @@ fun MediaActionButton(
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
focusedScale = focusedScale,
|
||||
focusedBorderWidth = focusedBorderWidth,
|
||||
focusedBorderColor = focusedBorderColor
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
@@ -16,8 +15,6 @@ fun PurefinIconButton(
|
||||
modifier: Modifier = Modifier,
|
||||
size: Int = 52,
|
||||
focusedScale: Float = 1f,
|
||||
focusedBorderWidth: Dp = 0.dp,
|
||||
focusedBorderColor: Color = Color.Transparent,
|
||||
focusedBackgroundColor: Color? = null
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
@@ -31,7 +28,5 @@ fun PurefinIconButton(
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
focusedScale = focusedScale,
|
||||
focusedBorderWidth = focusedBorderWidth,
|
||||
focusedBorderColor = focusedBorderColor
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package hu.bbara.purefin.ui.common.dialog
|
||||
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
@Composable
|
||||
fun ExitAppDialog(
|
||||
onCloseConfirmed: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Close app?") },
|
||||
text = { Text("Are you sure?") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = onCloseConfirmed) {
|
||||
Text("Close")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -3,21 +3,21 @@ package hu.bbara.purefin.ui.common.media
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun MediaMetadataFlowRow(
|
||||
items: List<String>,
|
||||
modifier: Modifier = Modifier,
|
||||
highlightedItem: String? = null,
|
||||
highlightedBackground: Color = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f),
|
||||
highlightedBorder: Color = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f),
|
||||
highlightedTextColor: Color = MaterialTheme.colorScheme.primary
|
||||
highlightedTextColor: Color = MaterialTheme.colorScheme.primary,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
FlowRow(
|
||||
modifier = modifier,
|
||||
|
||||
@@ -9,12 +9,13 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted.Companion.Eagerly
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
@@ -25,12 +26,13 @@ import javax.inject.Singleton
|
||||
class CompositeLocalMediaRepository @Inject constructor(
|
||||
@Offline private val offlineRepository: LocalMediaRepository,
|
||||
@Online private val onlineRepository: LocalMediaRepository,
|
||||
private val networkMonitor: NetworkMonitor,
|
||||
) : LocalMediaRepository {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
// TODO move this into the domain layer and there you can use NetworkMonitor. Data should be free of android stuff.
|
||||
private val activeRepository: Flow<LocalMediaRepository> = flowOf(onlineRepository)
|
||||
private val activeRepository: Flow<LocalMediaRepository> = networkMonitor.isOnline
|
||||
.map { isOnline -> if (isOnline) onlineRepository else offlineRepository }
|
||||
|
||||
override val movies: StateFlow<Map<UUID, Movie>> = activeRepository
|
||||
.flatMapLatest { it.movies }
|
||||
@@ -45,35 +47,123 @@ class CompositeLocalMediaRepository @Inject constructor(
|
||||
.stateIn(scope, Eagerly, emptyMap())
|
||||
|
||||
override suspend fun getMovie(id: UUID): Flow<Movie?> {
|
||||
return activeRepository
|
||||
.flatMapLatest { it.getMovie(id) }
|
||||
return getFromActiveRepository(
|
||||
onlineRead = { onlineRepository.getMovie(id) },
|
||||
offlineRead = { offlineRepository.getMovie(id) },
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun getSeries(id: UUID): Flow<Series?> {
|
||||
return activeRepository
|
||||
.flatMapLatest { it.getSeries(id) }
|
||||
return getFromActiveRepository(
|
||||
onlineRead = { onlineRepository.getSeries(id) },
|
||||
offlineRead = { offlineRepository.getSeries(id) },
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun getEpisode(id: UUID): Flow<Episode?> {
|
||||
return activeRepository
|
||||
.flatMapLatest { it.getEpisode(id) }
|
||||
return getFromActiveRepository(
|
||||
onlineRead = { onlineRepository.getEpisode(id) },
|
||||
offlineRead = { offlineRepository.getEpisode(id) },
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun loadSeasons(seriesId: UUID) {
|
||||
activeRepository.first().loadSeasons(seriesId)
|
||||
runOnlineOrOfflineNoOp(
|
||||
onlineAction = { onlineRepository.loadSeasons(seriesId) },
|
||||
offlineAction = { offlineRepository.loadSeasons(seriesId) },
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun loadSeasonEpisodes(seriesId: UUID, seasonId: UUID) {
|
||||
activeRepository.first().loadSeasonEpisodes(seriesId, seasonId)
|
||||
runOnlineOrOfflineNoOp(
|
||||
onlineAction = { onlineRepository.loadSeasonEpisodes(seriesId, seasonId) },
|
||||
offlineAction = { offlineRepository.loadSeasonEpisodes(seriesId, seasonId) },
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long) {
|
||||
onlineRepository.updateWatchProgress(mediaId, positionMs, durationMs)
|
||||
if (networkMonitor.isOnline.first()) {
|
||||
runOnlineAction { onlineRepository.updateWatchProgress(mediaId, positionMs, durationMs) }
|
||||
}
|
||||
offlineRepository.updateWatchProgress(mediaId, positionMs, durationMs)
|
||||
}
|
||||
|
||||
override suspend fun updateWatchProgressPercent(mediaId: UUID, progressPercent: Double) {
|
||||
if (networkMonitor.isOnline.first()) {
|
||||
runOnlineAction { onlineRepository.updateWatchProgressPercent(mediaId, progressPercent) }
|
||||
}
|
||||
offlineRepository.updateWatchProgressPercent(mediaId, progressPercent)
|
||||
}
|
||||
|
||||
override suspend fun markAsWatched(mediaId: UUID, watched: Boolean) {
|
||||
val repository = onlineRepository
|
||||
repository.markAsWatched(mediaId, watched)
|
||||
runOnlineOrOfflineNoOp(
|
||||
onlineAction = { onlineRepository.markAsWatched(mediaId, watched) },
|
||||
offlineAction = { offlineRepository.markAsWatched(mediaId, watched) },
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun updatePlaybackPosition(
|
||||
mediaId: UUID,
|
||||
playbackPositionTicks: Long,
|
||||
runtimeTicks: Long,
|
||||
) {
|
||||
// Server-side operation — delegates to active repository (both are no-ops)
|
||||
runOnlineOrOfflineNoOp(
|
||||
onlineAction = { onlineRepository.updatePlaybackPosition(mediaId, playbackPositionTicks, runtimeTicks) },
|
||||
offlineAction = { offlineRepository.updatePlaybackPosition(mediaId, playbackPositionTicks, runtimeTicks) },
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun <T> getFromActiveRepository(
|
||||
onlineRead: suspend () -> Flow<T>,
|
||||
offlineRead: suspend () -> Flow<T>,
|
||||
): Flow<T> {
|
||||
if (!networkMonitor.isOnline.first()) {
|
||||
return offlineRead()
|
||||
}
|
||||
return try {
|
||||
onlineRead()
|
||||
} catch (error: CancellationException) {
|
||||
throw error
|
||||
} catch (error: Exception) {
|
||||
if (!networkMonitor.checkConnection()) {
|
||||
offlineRead()
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun runOnlineOrOfflineNoOp(
|
||||
onlineAction: suspend () -> Unit,
|
||||
offlineAction: suspend () -> Unit,
|
||||
) {
|
||||
if (!networkMonitor.isOnline.first()) {
|
||||
offlineAction()
|
||||
return
|
||||
}
|
||||
try {
|
||||
onlineAction()
|
||||
} catch (error: CancellationException) {
|
||||
throw error
|
||||
} catch (error: Exception) {
|
||||
if (!networkMonitor.checkConnection()) {
|
||||
offlineAction()
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun runOnlineAction(action: suspend () -> Unit) {
|
||||
try {
|
||||
action()
|
||||
} catch (error: CancellationException) {
|
||||
throw error
|
||||
} catch (error: Exception) {
|
||||
if (networkMonitor.checkConnection()) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package hu.bbara.purefin.core.data
|
||||
|
||||
import hu.bbara.purefin.core.model.MediaUiModel
|
||||
import hu.bbara.purefin.model.Library
|
||||
import hu.bbara.purefin.model.Media
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -13,4 +14,15 @@ interface HomeRepository {
|
||||
val latestLibraryContent: StateFlow<Map<UUID, List<Media>>>
|
||||
fun ensureReady()
|
||||
suspend fun refreshHomeData()
|
||||
|
||||
/**
|
||||
* Fetches the full content (movies or series) of a single library on
|
||||
* demand. Used by the library detail screen; the home page no longer
|
||||
* pays for full library content on every refresh.
|
||||
*
|
||||
* Returns `null` when the server returned 304 Not Modified for the
|
||||
* per-library ETag — the caller should keep its previously fetched
|
||||
* copy. An empty list is a legitimate "the library is empty" result.
|
||||
*/
|
||||
suspend fun loadLibraryContent(libraryId: UUID): List<MediaUiModel>?
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import java.util.UUID
|
||||
|
||||
interface LocalMediaRepository : LocalMediaUpdater {
|
||||
interface LocalMediaRepository : MediaMetadataUpdater {
|
||||
val movies: StateFlow<Map<UUID, Movie>>
|
||||
val series: StateFlow<Map<UUID, Series>>
|
||||
val episodes: StateFlow<Map<UUID, Episode>>
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
package hu.bbara.purefin.core.data
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
interface LocalMediaUpdater {
|
||||
suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long)
|
||||
suspend fun markAsWatched(mediaId: UUID, watched: Boolean)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package hu.bbara.purefin.core.data
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Unified interface for updating media metadata (watched status, watch progress,
|
||||
* and server-side playback position).
|
||||
*
|
||||
* Implementations coordinate both local cache updates and remote server calls
|
||||
* so that callers have a single point of entry for all metadata mutations.
|
||||
*/
|
||||
interface MediaMetadataUpdater {
|
||||
/**
|
||||
* Mark the given media item as watched or unwatched.
|
||||
* Updates both the local cache and the remote server.
|
||||
*/
|
||||
suspend fun markAsWatched(mediaId: UUID, watched: Boolean)
|
||||
|
||||
/**
|
||||
* Compute progress from absolute position/duration and persist it
|
||||
* to the local cache (in-memory and/or Room).
|
||||
*/
|
||||
suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long)
|
||||
|
||||
/**
|
||||
* Persist a raw progress percentage (0.0 – 100.0) to the local cache.
|
||||
*/
|
||||
suspend fun updateWatchProgressPercent(mediaId: UUID, progressPercent: Double)
|
||||
|
||||
/**
|
||||
* Update the server-side playback position (in ticks) and optionally
|
||||
* mark the item as played when past the threshold (≥ 80 %).
|
||||
*/
|
||||
suspend fun updatePlaybackPosition(
|
||||
mediaId: UUID,
|
||||
playbackPositionTicks: Long,
|
||||
runtimeTicks: Long,
|
||||
)
|
||||
}
|
||||
@@ -4,8 +4,5 @@ import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface NetworkMonitor {
|
||||
val isOnline: Flow<Boolean>
|
||||
|
||||
fun reportRequestSucceeded()
|
||||
|
||||
fun reportRequestFailed(error: Throwable)
|
||||
suspend fun checkConnection(): Boolean
|
||||
}
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
package hu.bbara.purefin.core.data
|
||||
|
||||
import hu.bbara.purefin.model.Episode
|
||||
import hu.bbara.purefin.model.Movie
|
||||
import hu.bbara.purefin.model.Series
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import java.util.UUID
|
||||
|
||||
interface OfflineCatalogReader {
|
||||
val movies: StateFlow<Map<UUID, Movie>>
|
||||
val series: StateFlow<Map<UUID, Series>>
|
||||
val episodes: StateFlow<Map<UUID, Episode>>
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import hu.bbara.purefin.model.Season
|
||||
import hu.bbara.purefin.model.Series
|
||||
import java.util.UUID
|
||||
|
||||
interface OfflineCatalogStore {
|
||||
interface OfflineMediaManager {
|
||||
suspend fun saveMovies(movies: List<Movie>)
|
||||
suspend fun saveSeries(series: List<Series>)
|
||||
suspend fun saveSeason(season: Season)
|
||||
@@ -0,0 +1,12 @@
|
||||
package hu.bbara.purefin.core.data
|
||||
|
||||
/**
|
||||
* Playback-only data stored in [androidx.media3.common.MediaItem.LocalConfiguration.tag].
|
||||
*
|
||||
* The player uses [playbackReportContext] for Jellyfin progress reporting and consumes the
|
||||
* transcoding fallback fields once when direct playback fails.
|
||||
*/
|
||||
data class PlaybackMediaItemTag(
|
||||
val playbackReportContext: PlaybackReportContext,
|
||||
val transcodingFallbackUrl: String?,
|
||||
)
|
||||
@@ -5,6 +5,5 @@ data class PlaybackReportContext(
|
||||
val mediaSourceId: String?,
|
||||
val audioStreamIndex: Int?,
|
||||
val subtitleStreamIndex: Int?,
|
||||
val liveStreamId: String?,
|
||||
val playSessionId: String?,
|
||||
)
|
||||
|
||||
@@ -10,8 +10,9 @@ import androidx.media3.exoplayer.offline.DownloadManager
|
||||
import androidx.media3.exoplayer.offline.DownloadRequest
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import hu.bbara.purefin.core.data.DownloadMediaSourceResolver
|
||||
import hu.bbara.purefin.core.data.OfflineCatalogStore
|
||||
import hu.bbara.purefin.core.data.OfflineMediaManager
|
||||
import hu.bbara.purefin.core.data.SmartDownloadStore
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -35,7 +36,7 @@ class MediaDownloadManager @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val downloadManager: DownloadManager,
|
||||
private val downloadMediaSourceResolver: DownloadMediaSourceResolver,
|
||||
private val offlineCatalogStore: OfflineCatalogStore,
|
||||
private val offlineMediaManager: OfflineMediaManager,
|
||||
private val smartDownloadStore: SmartDownloadStore,
|
||||
) : MediaDownloadController {
|
||||
|
||||
@@ -124,7 +125,7 @@ class MediaDownloadManager @Inject constructor(
|
||||
return@withContext
|
||||
}
|
||||
|
||||
offlineCatalogStore.saveMovies(listOf(source.movie))
|
||||
offlineMediaManager.saveMovies(listOf(source.movie))
|
||||
|
||||
Timber.tag(TAG).d("Starting download for '${source.movie.title}' from: ${source.playbackUrl}")
|
||||
val request = buildDownloadRequest(
|
||||
@@ -146,7 +147,7 @@ class MediaDownloadManager @Inject constructor(
|
||||
withContext(Dispatchers.IO) {
|
||||
PurefinDownloadService.sendRemoveDownload(context, movieId.toString())
|
||||
try {
|
||||
offlineCatalogStore.deleteMovie(movieId)
|
||||
offlineMediaManager.deleteMovie(movieId)
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(TAG).e(e, "Failed to remove movie from offline DB")
|
||||
}
|
||||
@@ -161,15 +162,15 @@ class MediaDownloadManager @Inject constructor(
|
||||
return@withContext
|
||||
}
|
||||
|
||||
if (offlineCatalogStore.getSeriesBasic(source.series.id) == null) {
|
||||
offlineCatalogStore.saveSeries(listOf(source.series))
|
||||
if (offlineMediaManager.getSeriesBasic(source.series.id) == null) {
|
||||
offlineMediaManager.saveSeries(listOf(source.series))
|
||||
}
|
||||
|
||||
if (offlineCatalogStore.getSeason(source.season.id) == null) {
|
||||
offlineCatalogStore.saveSeason(source.season)
|
||||
if (offlineMediaManager.getSeason(source.season.id) == null) {
|
||||
offlineMediaManager.saveSeason(source.season)
|
||||
}
|
||||
|
||||
offlineCatalogStore.saveEpisode(source.episode)
|
||||
offlineMediaManager.saveEpisode(source.episode)
|
||||
|
||||
Timber.tag(TAG).d("Starting download for episode '${source.episode.title}' from: ${source.playbackUrl}")
|
||||
val request = buildDownloadRequest(
|
||||
@@ -199,7 +200,7 @@ class MediaDownloadManager @Inject constructor(
|
||||
withContext(Dispatchers.IO) {
|
||||
PurefinDownloadService.sendRemoveDownload(context, episodeId.toString())
|
||||
try {
|
||||
offlineCatalogStore.deleteEpisodeAndCleanup(episodeId)
|
||||
offlineMediaManager.deleteEpisodeAndCleanup(episodeId)
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(TAG).e(e, "Failed to remove episode from offline DB")
|
||||
}
|
||||
@@ -216,7 +217,7 @@ class MediaDownloadManager @Inject constructor(
|
||||
override suspend fun deleteSmartDownloads(seriesId: UUID) {
|
||||
withContext(Dispatchers.IO) {
|
||||
smartDownloadStore.disable(seriesId)
|
||||
offlineCatalogStore.getEpisodesBySeries(seriesId).forEach { episode ->
|
||||
offlineMediaManager.getEpisodesBySeries(seriesId).forEach { episode ->
|
||||
cancelEpisodeDownload(episode.id)
|
||||
}
|
||||
}
|
||||
@@ -230,6 +231,8 @@ class MediaDownloadManager @Inject constructor(
|
||||
for (seriesId in enabledSeriesIds) {
|
||||
try {
|
||||
syncSmartDownloadsForSeries(seriesId)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(TAG).e(e, "Smart download sync failed for series $seriesId")
|
||||
}
|
||||
@@ -240,7 +243,7 @@ class MediaDownloadManager @Inject constructor(
|
||||
private suspend fun syncSmartDownloadsForSeries(seriesId: UUID) {
|
||||
withContext(Dispatchers.IO) {
|
||||
// 1. Get currently downloaded episodes for this series
|
||||
val downloadedEpisodes = offlineCatalogStore.getEpisodesBySeries(seriesId)
|
||||
val downloadedEpisodes = offlineMediaManager.getEpisodesBySeries(seriesId)
|
||||
|
||||
// 2. Check watched status from server and delete watched downloads
|
||||
val unwatchedDownloaded = mutableListOf<UUID>()
|
||||
|
||||
@@ -7,8 +7,8 @@ import hu.bbara.purefin.core.data.HomeRepository
|
||||
import hu.bbara.purefin.core.data.LocalMediaRepository
|
||||
import hu.bbara.purefin.core.data.NetworkMonitor
|
||||
import hu.bbara.purefin.core.data.UserSessionRepository
|
||||
import hu.bbara.purefin.core.download.MediaDownloadController
|
||||
import hu.bbara.purefin.core.jellyfin.JellyfinMediaMetadataUpdater
|
||||
import hu.bbara.purefin.core.feature.browse.home.refresh.HomeRefreshCoordinator
|
||||
import hu.bbara.purefin.core.data.MediaMetadataUpdater
|
||||
import hu.bbara.purefin.core.settings.SettingsRepository
|
||||
import hu.bbara.purefin.core.model.EpisodeUiModel
|
||||
import hu.bbara.purefin.core.model.LibraryUiModel
|
||||
@@ -39,21 +39,24 @@ class AppViewModel @Inject constructor(
|
||||
private val homeRepository: HomeRepository,
|
||||
private val localMediaRepository: LocalMediaRepository,
|
||||
private val userSessionRepository: UserSessionRepository,
|
||||
private val jellyfinMediaMetadataUpdater: JellyfinMediaMetadataUpdater,
|
||||
private val mediaMetadataUpdater: MediaMetadataUpdater,
|
||||
private val navigationManager: NavigationManager,
|
||||
private val mediaDownloadManager: MediaDownloadController,
|
||||
private val homeRefreshCoordinator: HomeRefreshCoordinator,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
networkMonitor: NetworkMonitor,
|
||||
private val networkMonitor: NetworkMonitor,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _isRefreshing = MutableStateFlow(false)
|
||||
val isRefreshing: StateFlow<Boolean> = _isRefreshing.asStateFlow()
|
||||
|
||||
private val _isCheckingConnection = MutableStateFlow(false)
|
||||
val isCheckingConnection: StateFlow<Boolean> = _isCheckingConnection.asStateFlow()
|
||||
|
||||
val isOnline: StateFlow<Boolean> = networkMonitor.isOnline
|
||||
.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.Eagerly,
|
||||
false
|
||||
true
|
||||
)
|
||||
|
||||
val serverUrl: StateFlow<String> = userSessionRepository.serverUrl
|
||||
@@ -65,16 +68,17 @@ class AppViewModel @Inject constructor(
|
||||
|
||||
val libraries = homeRepository.libraries.map { libraries ->
|
||||
libraries.map {
|
||||
// The home page no longer fetches the full library content
|
||||
// (see InMemoryAppContentRepository.loadLibraries). isEmpty
|
||||
// therefore reflects the actual server-reported size, not
|
||||
// the contents of the local media repository.
|
||||
LibraryUiModel(
|
||||
id = it.id,
|
||||
name = it.name,
|
||||
type = it.type,
|
||||
posterUrl = it.posterUrl,
|
||||
size = it.size,
|
||||
isEmpty = when (it.type) {
|
||||
LibraryKind.MOVIES -> localMediaRepository.movies.value.isEmpty()
|
||||
LibraryKind.SERIES -> localMediaRepository.series.value.isEmpty()
|
||||
}
|
||||
isEmpty = it.size == 0,
|
||||
)
|
||||
}
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
@@ -182,7 +186,7 @@ class AppViewModel @Inject constructor(
|
||||
|
||||
fun markAsWatched(mediaUiModel: MediaUiModel, watched: Boolean) {
|
||||
viewModelScope.launch {
|
||||
jellyfinMediaMetadataUpdater.markAsWatched(mediaUiModel.id, watched)
|
||||
mediaMetadataUpdater.markAsWatched(mediaUiModel.id, watched)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,34 +257,31 @@ class AppViewModel @Inject constructor(
|
||||
|
||||
fun onResumed() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
homeRepository.refreshHomeData()
|
||||
} catch (e: Exception) {
|
||||
// Refresh is best-effort; don't crash on failure
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
mediaDownloadManager.syncSmartDownloads()
|
||||
} catch (_: Exception) { }
|
||||
networkMonitor.checkConnection()
|
||||
homeRefreshCoordinator.onResumed()
|
||||
}
|
||||
}
|
||||
|
||||
fun onRefresh() {
|
||||
viewModelScope.launch {
|
||||
_isRefreshing.value = true
|
||||
try {
|
||||
homeRepository.refreshHomeData()
|
||||
} catch (e: Exception) {
|
||||
// Refresh is best-effort; don't crash on failure
|
||||
} finally {
|
||||
_isRefreshing.value = false
|
||||
networkMonitor.checkConnection()
|
||||
homeRefreshCoordinator.onRefresh { isRefreshing ->
|
||||
_isRefreshing.value = isRefreshing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun checkConnection() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
mediaDownloadManager.syncSmartDownloads()
|
||||
} catch (_: Exception) { }
|
||||
if (_isCheckingConnection.value) return@launch
|
||||
_isCheckingConnection.value = true
|
||||
val isConnected = networkMonitor.checkConnection()
|
||||
_isCheckingConnection.value = false
|
||||
if (isConnected) {
|
||||
homeRefreshCoordinator.onRefresh { isRefreshing ->
|
||||
_isRefreshing.value = isRefreshing
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package hu.bbara.purefin.core.feature.browse.home.refresh
|
||||
|
||||
import android.os.SystemClock
|
||||
import hu.bbara.purefin.core.data.HomeRepository
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.concurrent.atomics.AtomicLong
|
||||
import kotlin.concurrent.atomics.ExperimentalAtomicApi
|
||||
|
||||
// Singleton so the debounce window is shared across all ViewModel instances.
|
||||
// A new coordinator per ViewModel would reset the timer on configuration
|
||||
// changes and defeat the purpose of coalescing rapid resume/tab-switch
|
||||
// events.
|
||||
@Singleton
|
||||
class HomeRefreshCoordinator @Inject constructor(
|
||||
private val homeRepository: HomeRepository,
|
||||
private val sideEffects: Set<@JvmSuppressWildcards HomeRefreshSideEffect>,
|
||||
) {
|
||||
private val sideEffectsMutex = Mutex()
|
||||
|
||||
// AtomicLong matches the rate-limit pattern used by the playback-position
|
||||
// side effect and is safe to read/write from any coroutine dispatcher.
|
||||
@OptIn(ExperimentalAtomicApi::class)
|
||||
private val lastRefreshAtMs = AtomicLong(0L)
|
||||
|
||||
@OptIn(ExperimentalAtomicApi::class)
|
||||
suspend fun onResumed() {
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
if (now - lastRefreshAtMs.load() < RESUME_DEBOUNCE_MS) {
|
||||
Timber.tag(TAG).d("Skipping onResumed refresh (debounced)")
|
||||
return
|
||||
}
|
||||
refreshHomeData()
|
||||
runSideEffects()
|
||||
// Updated unconditionally on completion. refreshHomeData() catches
|
||||
// non-cancellation exceptions internally, so a failed refresh still
|
||||
// counts as "we just touched the server" and the next onResumed
|
||||
// within RESUME_DEBOUNCE_MS will be skipped. Pull-to-refresh always
|
||||
// bypasses this gate via onRefresh.
|
||||
lastRefreshAtMs.store(SystemClock.elapsedRealtime())
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalAtomicApi::class)
|
||||
suspend fun onRefresh(setRefreshing: (Boolean) -> Unit) {
|
||||
refreshHomeData(setRefreshing)
|
||||
runSideEffects()
|
||||
lastRefreshAtMs.store(SystemClock.elapsedRealtime())
|
||||
}
|
||||
|
||||
private suspend fun refreshHomeData(
|
||||
setRefreshing: ((Boolean) -> Unit)? = null,
|
||||
) {
|
||||
setRefreshing?.invoke(true)
|
||||
try {
|
||||
homeRepository.refreshHomeData()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
// Refresh is best-effort; don't crash on failure
|
||||
} finally {
|
||||
setRefreshing?.invoke(false)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun runSideEffects() {
|
||||
sideEffectsMutex.withLock {
|
||||
coroutineScope {
|
||||
sideEffects.forEach { sideEffect ->
|
||||
launch {
|
||||
try {
|
||||
sideEffect.run()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "HomeRefreshCoordinator"
|
||||
// Coalesce rapid resume/tab-switch events into a single refresh.
|
||||
// Pull-to-refresh bypasses this gate via onRefresh.
|
||||
const val RESUME_DEBOUNCE_MS = 30_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package hu.bbara.purefin.core.feature.browse.home.refresh
|
||||
|
||||
interface HomeRefreshSideEffect {
|
||||
suspend fun run()
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package hu.bbara.purefin.core.feature.browse.home.refresh
|
||||
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.IntoSet
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class HomeRefreshSideEffectModule {
|
||||
|
||||
@Binds
|
||||
@IntoSet
|
||||
abstract fun bindSyncSmartDownloadsHomeRefreshSideEffect(
|
||||
impl: SyncSmartDownloadsHomeRefreshSideEffect
|
||||
): HomeRefreshSideEffect
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package hu.bbara.purefin.core.feature.browse.home.refresh
|
||||
|
||||
import hu.bbara.purefin.core.download.MediaDownloadController
|
||||
import javax.inject.Inject
|
||||
|
||||
class SyncSmartDownloadsHomeRefreshSideEffect @Inject constructor(
|
||||
private val mediaDownloadController: MediaDownloadController,
|
||||
) : HomeRefreshSideEffect {
|
||||
|
||||
override suspend fun run() {
|
||||
mediaDownloadController.syncSmartDownloads()
|
||||
}
|
||||
}
|
||||
@@ -4,19 +4,15 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import hu.bbara.purefin.core.data.HomeRepository
|
||||
import hu.bbara.purefin.core.data.MediaMetadataUpdater
|
||||
import hu.bbara.purefin.core.model.MediaUiModel
|
||||
import hu.bbara.purefin.core.model.MovieUiModel
|
||||
import hu.bbara.purefin.core.model.SeriesUiModel
|
||||
import hu.bbara.purefin.core.navigation.MovieDto
|
||||
import hu.bbara.purefin.core.navigation.NavigationManager
|
||||
import hu.bbara.purefin.core.navigation.Route
|
||||
import hu.bbara.purefin.core.navigation.SeriesDto
|
||||
import hu.bbara.purefin.model.LibraryKind
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
@@ -25,28 +21,40 @@ import javax.inject.Inject
|
||||
class LibraryViewModel @Inject constructor(
|
||||
private val homeRepository: HomeRepository,
|
||||
private val navigationManager: NavigationManager,
|
||||
private val mediaMetadataUpdater: MediaMetadataUpdater,
|
||||
) : ViewModel() {
|
||||
|
||||
private val selectedLibrary = MutableStateFlow<UUID?>(null)
|
||||
|
||||
val contents: StateFlow<List<MediaUiModel>> = combine(selectedLibrary, homeRepository.libraries) {
|
||||
libraryId, libraries ->
|
||||
if (libraryId == null) {
|
||||
return@combine emptyList()
|
||||
}
|
||||
val library = libraries.find { it.id == libraryId } ?: return@combine emptyList()
|
||||
when (library.type) {
|
||||
LibraryKind.SERIES -> library.series!!.map { series ->
|
||||
SeriesUiModel(series)
|
||||
}
|
||||
LibraryKind.MOVIES -> library.movies!!.map { movie ->
|
||||
MovieUiModel(movie)
|
||||
}
|
||||
}
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
// Local cache of the last-fetched content per library. Used to preserve
|
||||
// content when re-selecting a library whose ETag matches (so the
|
||||
// repository's 304 response can be treated as "use the cached copy").
|
||||
private val cachedContents = mutableMapOf<UUID, List<MediaUiModel>>()
|
||||
|
||||
private val _contents = MutableStateFlow<List<MediaUiModel>>(emptyList())
|
||||
val contents: StateFlow<List<MediaUiModel>> = _contents.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch { homeRepository.ensureReady() }
|
||||
viewModelScope.launch {
|
||||
selectedLibrary.collect { libraryId ->
|
||||
if (libraryId == null) {
|
||||
_contents.value = emptyList()
|
||||
} else {
|
||||
val fresh = homeRepository.loadLibraryContent(libraryId)
|
||||
if (fresh != null) {
|
||||
cachedContents[libraryId] = fresh
|
||||
_contents.value = fresh
|
||||
} else {
|
||||
// ETag 304 — keep the cached copy for this library if
|
||||
// we have one. On a first-visit 304 (rare, would
|
||||
// require the library's ETag to be set without any
|
||||
// prior fetch) the screen briefly shows empty.
|
||||
_contents.value = cachedContents[libraryId] ?: emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onMovieSelected(movieId: UUID) {
|
||||
@@ -69,6 +77,12 @@ class LibraryViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun markAsWatched(mediaUiModel: MediaUiModel, watched: Boolean) {
|
||||
viewModelScope.launch {
|
||||
mediaMetadataUpdater.markAsWatched(mediaUiModel.id, watched)
|
||||
}
|
||||
}
|
||||
|
||||
fun onBack() {
|
||||
navigationManager.pop()
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import hu.bbara.purefin.core.Offline
|
||||
import hu.bbara.purefin.core.data.LocalMediaRepository
|
||||
import hu.bbara.purefin.core.download.DownloadState
|
||||
import hu.bbara.purefin.core.download.MediaDownloadController
|
||||
import hu.bbara.purefin.core.data.MediaMetadataUpdater
|
||||
import hu.bbara.purefin.core.navigation.EpisodeDto
|
||||
import hu.bbara.purefin.core.navigation.NavigationManager
|
||||
import hu.bbara.purefin.core.navigation.Route
|
||||
@@ -30,6 +31,7 @@ class EpisodeScreenViewModel @Inject constructor(
|
||||
@param:Offline private val offlineMediaCatalogReader: LocalMediaRepository,
|
||||
private val navigationManager: NavigationManager,
|
||||
private val mediaDownloadManager: MediaDownloadController,
|
||||
private val mediaMetadataUpdater: MediaMetadataUpdater,
|
||||
): ViewModel() {
|
||||
|
||||
private val _episode = MutableStateFlow<EpisodeDto?>(null)
|
||||
@@ -81,6 +83,13 @@ class EpisodeScreenViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun markAsWatched(watched: Boolean) {
|
||||
val episodeId = _episode.value?.id ?: return
|
||||
viewModelScope.launch {
|
||||
mediaMetadataUpdater.markAsWatched(episodeId, watched)
|
||||
}
|
||||
}
|
||||
|
||||
fun onDownloadClick() {
|
||||
val episodeId = _episode.value?.id ?: return
|
||||
viewModelScope.launch {
|
||||
|
||||
@@ -7,6 +7,7 @@ import hu.bbara.purefin.core.Offline
|
||||
import hu.bbara.purefin.core.data.LocalMediaRepository
|
||||
import hu.bbara.purefin.core.download.DownloadState
|
||||
import hu.bbara.purefin.core.download.MediaDownloadController
|
||||
import hu.bbara.purefin.core.data.MediaMetadataUpdater
|
||||
import hu.bbara.purefin.core.navigation.MovieDto
|
||||
import hu.bbara.purefin.core.navigation.NavigationManager
|
||||
import hu.bbara.purefin.core.navigation.Route
|
||||
@@ -28,6 +29,7 @@ class MovieScreenViewModel @Inject constructor(
|
||||
@param:Offline private val offlineMediaCatalogReader: LocalMediaRepository,
|
||||
private val navigationManager: NavigationManager,
|
||||
private val mediaDownloadManager: MediaDownloadController,
|
||||
private val mediaMetadataUpdater: MediaMetadataUpdater,
|
||||
): ViewModel() {
|
||||
|
||||
private val _movie = MutableStateFlow<MovieDto?>(null)
|
||||
@@ -68,6 +70,13 @@ class MovieScreenViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun markAsWatched(watched: Boolean) {
|
||||
val movieId = movie.value?.id ?: return
|
||||
viewModelScope.launch {
|
||||
mediaMetadataUpdater.markAsWatched(movieId, watched)
|
||||
}
|
||||
}
|
||||
|
||||
fun onDownloadClick() {
|
||||
val movieId = movie.value?.id ?: return
|
||||
viewModelScope.launch {
|
||||
|
||||
@@ -7,6 +7,7 @@ import hu.bbara.purefin.core.Offline
|
||||
import hu.bbara.purefin.core.data.LocalMediaRepository
|
||||
import hu.bbara.purefin.core.download.DownloadState
|
||||
import hu.bbara.purefin.core.download.MediaDownloadController
|
||||
import hu.bbara.purefin.core.data.MediaMetadataUpdater
|
||||
import hu.bbara.purefin.core.navigation.EpisodeDto
|
||||
import hu.bbara.purefin.core.navigation.NavigationManager
|
||||
import hu.bbara.purefin.core.navigation.Route
|
||||
@@ -33,6 +34,7 @@ class SeriesViewModel @Inject constructor(
|
||||
@param:Offline private val offlineMediaCatalogReader: LocalMediaRepository,
|
||||
private val navigationManager: NavigationManager,
|
||||
private val mediaDownloadManager: MediaDownloadController,
|
||||
private val mediaMetadataUpdater: MediaMetadataUpdater,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _series = MutableStateFlow<SeriesDto?>(null)
|
||||
@@ -183,6 +185,13 @@ class SeriesViewModel @Inject constructor(
|
||||
navigationManager.replaceAll(Route.Home)
|
||||
}
|
||||
|
||||
fun markAsWatched(watched: Boolean) {
|
||||
val seriesId = _series.value?.id ?: return
|
||||
viewModelScope.launch {
|
||||
mediaMetadataUpdater.markAsWatched(seriesId, watched)
|
||||
}
|
||||
}
|
||||
|
||||
fun selectSeries(series: SeriesDto) {
|
||||
_series.value = series
|
||||
viewModelScope.launch {
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package hu.bbara.purefin.core.feature.downloads
|
||||
|
||||
import hu.bbara.purefin.core.model.MediaUiModel
|
||||
|
||||
data class DownloadedItem(
|
||||
val media: MediaUiModel,
|
||||
)
|
||||
@@ -48,16 +48,25 @@ class DownloadsViewModel @Inject constructor(
|
||||
private val activeDownloadsMap = downloadManager.observeActiveDownloads()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap())
|
||||
|
||||
/** Items that are fully downloaded and not currently in progress. */
|
||||
/** Offline items with saved content available, excluding items that are only currently in progress. */
|
||||
val downloads = combine(
|
||||
offlineCatalogReader.movies,
|
||||
offlineCatalogReader.series,
|
||||
activeDownloadsMap
|
||||
) { movies, series, inProgress ->
|
||||
offlineCatalogReader.episodes,
|
||||
activeDownloadsMap,
|
||||
) { movies, series, episodes, inProgress ->
|
||||
movies.values
|
||||
.filter { it.id.toString() !in inProgress }
|
||||
.map { MovieUiModel(it) } +
|
||||
series.values.map { SeriesUiModel(it) }
|
||||
.map { DownloadedItem(media = MovieUiModel(it)) } +
|
||||
series.values.filter { series ->
|
||||
episodes.values.any { episode ->
|
||||
episode.seriesId == series.id && episode.id.toString() !in inProgress
|
||||
}
|
||||
}.map {
|
||||
DownloadedItem(
|
||||
media = SeriesUiModel(it),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Items currently being downloaded with their progress. */
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
package hu.bbara.purefin.core.jellyfin
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
interface JellyfinMediaMetadataUpdater {
|
||||
suspend fun markAsWatched(mediaId: UUID, watched: Boolean)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package hu.bbara.purefin.core.jellyfin
|
||||
|
||||
import javax.inject.Qualifier
|
||||
|
||||
/**
|
||||
* Marks an `OkHttpClient` that is wired into the Jellyfin SDK's `OkHttpFactory`.
|
||||
* Distinct from the unqualified client used for image loading and media streaming
|
||||
* because the SDK has its own auth path and the ETag interceptor should not leak
|
||||
* into non-SDK HTTP calls.
|
||||
*/
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class JellyfinSdkClient
|
||||
@@ -6,7 +6,6 @@ import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import hu.bbara.purefin.core.data.CompositeLocalMediaRepository
|
||||
import hu.bbara.purefin.core.data.LocalMediaRepository
|
||||
import hu.bbara.purefin.core.data.LocalMediaUpdater
|
||||
|
||||
|
||||
@Module
|
||||
@@ -15,7 +14,4 @@ abstract class RepositoryModule {
|
||||
|
||||
@Binds
|
||||
abstract fun bindMediaRepository(impl: CompositeLocalMediaRepository): LocalMediaRepository
|
||||
|
||||
@Binds
|
||||
abstract fun bindMediaProgressWrite(impl: CompositeLocalMediaRepository): LocalMediaUpdater
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import dagger.hilt.android.scopes.ViewModelScoped
|
||||
import hu.bbara.purefin.core.data.PlayableMediaRepository
|
||||
import hu.bbara.purefin.core.data.PlaybackMediaItemTag
|
||||
import hu.bbara.purefin.core.data.PlaybackMethod
|
||||
import hu.bbara.purefin.core.data.PlaybackReportContext
|
||||
import hu.bbara.purefin.core.player.model.MetadataState
|
||||
import hu.bbara.purefin.core.player.model.PlaybackProgressSnapshot
|
||||
@@ -76,6 +78,7 @@ class PlayerManager @Inject constructor(
|
||||
}
|
||||
|
||||
private var pendingSeekPositionMs: Long? = null
|
||||
private var pendingFallbackSeek: PendingFallbackSeek? = null
|
||||
private val _playbackState = MutableStateFlow(PlaybackStateSnapshot())
|
||||
|
||||
val playbackState: StateFlow<PlaybackStateSnapshot> = _playbackState.asStateFlow()
|
||||
@@ -107,6 +110,9 @@ class PlayerManager @Inject constructor(
|
||||
}
|
||||
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
if (switchToTranscodingFallback()) {
|
||||
return
|
||||
}
|
||||
_playbackState.update {
|
||||
it.copy(error = mapPlayerError(error))
|
||||
}
|
||||
@@ -122,10 +128,15 @@ class PlayerManager @Inject constructor(
|
||||
_playbackState.update { it.copy(error = "Media not found in playlist") }
|
||||
return@launch
|
||||
}
|
||||
seekTo(currentMedia.resumePositionMs)
|
||||
val fallbackSeek = pendingFallbackSeek
|
||||
pendingFallbackSeek = null
|
||||
val fallbackSeekPositionMs = fallbackSeek
|
||||
?.takeIf { it.mediaId == mediaItem?.mediaId }
|
||||
?.positionMs
|
||||
seekTo(fallbackSeekPositionMs ?: currentMedia.resumePositionMs)
|
||||
installMediaSegments(currentMedia.mediaSegments)
|
||||
// Only updatePlaylist when episodes are being played. First item is handled when playMedia is being called first time.
|
||||
if (currentMedia is PlayableMedia.Episode) {
|
||||
if (fallbackSeekPositionMs == null && currentMedia is PlayableMedia.Episode) {
|
||||
updatePlaylist()
|
||||
}
|
||||
}
|
||||
@@ -177,7 +188,7 @@ class PlayerManager @Inject constructor(
|
||||
}
|
||||
|
||||
private suspend fun playNewMedia(mediaId: UUID) {
|
||||
var playableMedia = playableMediaRepository.getPlayableMedia(mediaId)
|
||||
val playableMedia = playableMediaRepository.getPlayableMedia(mediaId)
|
||||
if (playableMedia == null) {
|
||||
_playbackState.update { it.copy(error = "Media not found") }
|
||||
return
|
||||
@@ -424,7 +435,7 @@ class PlayerManager @Inject constructor(
|
||||
private fun updateFromPlayer(player: Player) {
|
||||
val currentMediaItem = player.currentMediaItem
|
||||
val playbackState = player.playbackState
|
||||
val playbackReportContext = currentMediaItem?.localConfiguration?.tag as? PlaybackReportContext
|
||||
val playbackReportContext = currentMediaItem?.playbackReportContext()
|
||||
val currentMetadata = player.mediaMetadata
|
||||
val playerError = player.playerError
|
||||
|
||||
@@ -487,6 +498,96 @@ class PlayerManager @Inject constructor(
|
||||
return error?.errorCodeName ?: error?.localizedMessage ?: error?.message
|
||||
}
|
||||
|
||||
private fun switchToTranscodingFallback(): Boolean {
|
||||
val currentMediaItem = player.currentMediaItem ?: return false
|
||||
val playbackTag = currentMediaItem.localConfiguration?.tag as? PlaybackMediaItemTag
|
||||
?: return false
|
||||
val fallbackUrl = playbackTag.transcodingFallbackUrl?.takeIf { it.isNotBlank() } ?: return false
|
||||
val currentMediaItemIndex = player.currentMediaItemIndex
|
||||
if (currentMediaItemIndex == C.INDEX_UNSET) return false
|
||||
|
||||
val fallbackMediaItem = currentMediaItem.buildUpon()
|
||||
.setUri(fallbackUrl)
|
||||
.setTag(
|
||||
playbackTag.copy(
|
||||
playbackReportContext = playbackTag.playbackReportContext.copy(
|
||||
playMethod = PlaybackMethod.TRANSCODE
|
||||
),
|
||||
transcodingFallbackUrl = null,
|
||||
)
|
||||
)
|
||||
.build()
|
||||
val fallbackPositionMs = fallbackPositionMs()
|
||||
val playWhenReady = player.playWhenReady
|
||||
|
||||
pendingFallbackSeek = PendingFallbackSeek(
|
||||
mediaId = currentMediaItem.mediaId,
|
||||
positionMs = fallbackPositionMs,
|
||||
)
|
||||
clearStalePendingFallbackSeek(currentMediaItem.mediaId)
|
||||
player.replaceMediaItem(currentMediaItemIndex, fallbackMediaItem)
|
||||
replacePlaylistMediaItem(fallbackMediaItem)
|
||||
clearActiveSkippableSegment()
|
||||
player.seekTo(currentMediaItemIndex, fallbackPositionMs)
|
||||
player.prepare()
|
||||
player.playWhenReady = playWhenReady
|
||||
_playbackState.update { it.copy(error = null) }
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private fun fallbackPositionMs(): Long {
|
||||
val positionMs = pendingSeekPositionMs ?: player.currentPosition
|
||||
return if (positionMs > 0L) {
|
||||
positionMs
|
||||
} else {
|
||||
_progress.value.positionMs.coerceAtLeast(0L)
|
||||
}
|
||||
}
|
||||
|
||||
private fun replacePlaylistMediaItem(mediaItem: MediaItem) {
|
||||
val mediaId = runCatching { UUID.fromString(mediaItem.mediaId) }.getOrNull() ?: return
|
||||
_playlist.update { playlist ->
|
||||
playlist.map { playableMedia ->
|
||||
if (playableMedia.id == mediaId) {
|
||||
playableMedia.withMediaItem(mediaItem)
|
||||
} else {
|
||||
playableMedia
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun PlayableMedia.withMediaItem(mediaItem: MediaItem): PlayableMedia {
|
||||
return when (this) {
|
||||
is PlayableMedia.Episode -> copy(mediaItem = mediaItem)
|
||||
is PlayableMedia.Movie -> copy(mediaItem = mediaItem)
|
||||
is PlayableMedia.Series -> copy(mediaItem = mediaItem)
|
||||
}
|
||||
}
|
||||
|
||||
private fun MediaItem.playbackReportContext(): PlaybackReportContext? {
|
||||
return when (val tag = localConfiguration?.tag) {
|
||||
is PlaybackMediaItemTag -> tag.playbackReportContext
|
||||
is PlaybackReportContext -> tag
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearStalePendingFallbackSeek(mediaId: String) {
|
||||
scope.launch {
|
||||
delay(1_000)
|
||||
if (pendingFallbackSeek?.mediaId == mediaId) {
|
||||
pendingFallbackSeek = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class PendingFallbackSeek(
|
||||
val mediaId: String,
|
||||
val positionMs: Long,
|
||||
)
|
||||
|
||||
private fun syncPendingSeek(positionMs: Long) {
|
||||
val pendingPosition = pendingSeekPositionMs ?: return
|
||||
if (abs(positionMs - pendingPosition) <= SEEK_SETTLE_TOLERANCE_MS) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package hu.bbara.purefin.core.player.manager
|
||||
|
||||
import dagger.hilt.android.scopes.ViewModelScoped
|
||||
import hu.bbara.purefin.core.data.LocalMediaUpdater
|
||||
import hu.bbara.purefin.core.data.MediaMetadataUpdater
|
||||
import hu.bbara.purefin.core.data.PlaybackProgressReporter
|
||||
import hu.bbara.purefin.core.data.PlaybackReportContext
|
||||
import hu.bbara.purefin.core.player.model.MetadataState
|
||||
@@ -24,7 +24,7 @@ import javax.inject.Inject
|
||||
@ViewModelScoped
|
||||
class ProgressManager @Inject constructor(
|
||||
private val playbackProgressReporter: PlaybackProgressReporter,
|
||||
private val localMediaUpdater: LocalMediaUpdater,
|
||||
private val mediaMetadataUpdater: MediaMetadataUpdater,
|
||||
) {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
private var progressJob: Job? = null
|
||||
@@ -80,7 +80,7 @@ class ProgressManager @Inject constructor(
|
||||
report(itemId, lastPositionMs, reportContext = activePlaybackReportContext, isStop = true)
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
localMediaUpdater.updateWatchProgress(itemId, lastPositionMs, lastDurationMs)
|
||||
mediaMetadataUpdater.updateWatchProgress(itemId, lastPositionMs, lastDurationMs)
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(TAG).e(e, "Local cache update failed")
|
||||
}
|
||||
@@ -127,7 +127,7 @@ class ProgressManager @Inject constructor(
|
||||
activePlaybackReportContext?.let { reportContext ->
|
||||
playbackProgressReporter.reportPlaybackStopped(itemId, ticks, reportContext)
|
||||
}
|
||||
localMediaUpdater.updateWatchProgress(itemId, posMs, durMs)
|
||||
mediaMetadataUpdater.updateWatchProgress(itemId, posMs, durMs)
|
||||
Timber.tag(TAG).d("Stop: $itemId at ${posMs}ms")
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(TAG).e(e, "Report failed")
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package hu.bbara.purefin.core.player.model
|
||||
|
||||
import hu.bbara.purefin.model.SegmentType
|
||||
|
||||
data class PlayerUiState(
|
||||
val isPlaying: Boolean = false,
|
||||
val isBuffering: Boolean = false,
|
||||
@@ -11,11 +13,13 @@ data class PlayerUiState(
|
||||
val positionMs: Long = 0L,
|
||||
val bufferedMs: Long = 0L,
|
||||
val activeSkippableSegmentEndMs: Long? = null,
|
||||
val activeSkippableSegmentType: SegmentType? = null,
|
||||
val error: String? = null,
|
||||
val playbackSpeed: Float = 1f,
|
||||
val chapters: List<TimedMarker> = emptyList(),
|
||||
val ads: List<TimedMarker> = emptyList(),
|
||||
val queue: List<PlaylistElementUiModel> = emptyList(),
|
||||
val nextEpisode: PlaylistElementUiModel? = null,
|
||||
val audioTracks: List<TrackOption> = emptyList(),
|
||||
val textTracks: List<TrackOption> = emptyList(),
|
||||
val qualityTracks: List<TrackOption> = emptyList(),
|
||||
|
||||
@@ -13,6 +13,7 @@ import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.SeekParameters
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
||||
import androidx.media3.exoplayer.util.EventLogger
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
@@ -69,6 +70,7 @@ object VideoPlayerModule {
|
||||
.apply {
|
||||
playWhenReady = true
|
||||
pauseAtEndOfMediaItems = false
|
||||
addAnalyticsListener(EventLogger())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
package hu.bbara.purefin.core.player.viewmodel
|
||||
|
||||
enum class ControlsAutoHideBlocker {
|
||||
PLAYLIST,
|
||||
TRACK_PANEL
|
||||
}
|
||||
|
||||
internal sealed interface ControlsAutoHideCommand {
|
||||
data object Cancel : ControlsAutoHideCommand
|
||||
data class Schedule(val delayMs: Long) : ControlsAutoHideCommand
|
||||
}
|
||||
|
||||
internal class ControlsAutoHidePolicy(
|
||||
private val defaultDelayMs: Long
|
||||
) {
|
||||
private val blockers = mutableSetOf<ControlsAutoHideBlocker>()
|
||||
private var isPlaying = false
|
||||
|
||||
var controlsVisible: Boolean = true
|
||||
private set
|
||||
|
||||
var lastAutoHideDelayMs: Long = defaultDelayMs
|
||||
private set
|
||||
|
||||
fun onPlaybackChanged(isPlaying: Boolean): ControlsAutoHideCommand {
|
||||
this.isPlaying = isPlaying
|
||||
return nextCommand()
|
||||
}
|
||||
|
||||
fun setAutoHideDelay(delayMs: Long): ControlsAutoHideCommand {
|
||||
lastAutoHideDelayMs = delayMs
|
||||
return nextCommand()
|
||||
}
|
||||
|
||||
fun showControls(delayMs: Long? = null): ControlsAutoHideCommand {
|
||||
delayMs?.let { lastAutoHideDelayMs = it }
|
||||
controlsVisible = true
|
||||
return nextCommand()
|
||||
}
|
||||
|
||||
fun toggleControlsVisibility(): ControlsAutoHideCommand {
|
||||
controlsVisible = !controlsVisible
|
||||
return nextCommand()
|
||||
}
|
||||
|
||||
fun hideControls(): ControlsAutoHideCommand {
|
||||
controlsVisible = false
|
||||
return ControlsAutoHideCommand.Cancel
|
||||
}
|
||||
|
||||
fun setBlocked(
|
||||
blocker: ControlsAutoHideBlocker,
|
||||
blocked: Boolean
|
||||
): ControlsAutoHideCommand {
|
||||
if (blocked) {
|
||||
blockers += blocker
|
||||
controlsVisible = true
|
||||
} else {
|
||||
blockers -= blocker
|
||||
}
|
||||
return nextCommand()
|
||||
}
|
||||
|
||||
private fun nextCommand(): ControlsAutoHideCommand {
|
||||
return if (!controlsVisible || !isPlaying || blockers.isNotEmpty()) {
|
||||
ControlsAutoHideCommand.Cancel
|
||||
} else {
|
||||
ControlsAutoHideCommand.Schedule(lastAutoHideDelayMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import hu.bbara.purefin.core.player.model.PlaylistElementUiModel
|
||||
import hu.bbara.purefin.core.player.model.TrackOption
|
||||
import hu.bbara.purefin.model.PlayableMedia
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -22,8 +21,8 @@ import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import kotlinx.serialization.InternalSerializationApi
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -46,10 +45,6 @@ class PlayerViewModel @Inject constructor(
|
||||
private val _uiState = MutableStateFlow(PlayerUiState())
|
||||
val uiState: StateFlow<PlayerUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val _controlsVisible = MutableStateFlow(true)
|
||||
val controlsVisible: StateFlow<Boolean> = _controlsVisible.asStateFlow()
|
||||
|
||||
private val controlsAutoHidePolicy = ControlsAutoHidePolicy(DEFAULT_CONTROLS_AUTO_HIDE_MS)
|
||||
private val seekByCollector = SeekByCollector(viewModelScope) { deltaMs ->
|
||||
playerManager.seekBy(deltaMs)
|
||||
}
|
||||
@@ -77,12 +72,6 @@ class PlayerViewModel @Inject constructor(
|
||||
error = state.error ?: dataErrorMessage
|
||||
)
|
||||
}
|
||||
applyControlsAutoHideCommand(
|
||||
controlsAutoHidePolicy.onPlaybackChanged(state.isPlaying)
|
||||
)
|
||||
if (state.isEnded) {
|
||||
showControls()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,7 +117,10 @@ class PlayerViewModel @Inject constructor(
|
||||
viewModelScope.launch {
|
||||
playerManager.activeSkippableSegment.collect { mediaSegment ->
|
||||
_uiState.update {
|
||||
it.copy(activeSkippableSegmentEndMs = mediaSegment?.endMs)
|
||||
it.copy(
|
||||
activeSkippableSegmentEndMs = mediaSegment?.endMs,
|
||||
activeSkippableSegmentType = mediaSegment?.type
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -139,9 +131,16 @@ class PlayerViewModel @Inject constructor(
|
||||
playableMedia.toPlaylistElementUiModel(currentPlayableMedia?.id)
|
||||
}
|
||||
}.collect { queue ->
|
||||
val currentIndex = queue.indexOfFirst { it.isCurrent }
|
||||
val nextEpisode = if (currentIndex != -1 && currentIndex + 1 < queue.size) {
|
||||
queue[currentIndex + 1]
|
||||
} else {
|
||||
null
|
||||
}
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
queue = queue
|
||||
queue = queue,
|
||||
nextEpisode = nextEpisode
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -181,9 +180,8 @@ class PlayerViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun togglePlayPause(autoHideDelayMs: Long = DEFAULT_CONTROLS_AUTO_HIDE_MS) {
|
||||
fun togglePlayPause() {
|
||||
playerManager.togglePlayPause()
|
||||
showControls(autoHideDelayMs)
|
||||
}
|
||||
|
||||
fun pausePlayback() {
|
||||
@@ -213,49 +211,14 @@ class PlayerViewModel @Inject constructor(
|
||||
playerManager.skipActiveSegment()
|
||||
}
|
||||
|
||||
fun setControlsAutoHideBlocked(
|
||||
blocker: ControlsAutoHideBlocker,
|
||||
blocked: Boolean
|
||||
) {
|
||||
applyControlsAutoHideCommand(
|
||||
controlsAutoHidePolicy.setBlocked(blocker, blocked)
|
||||
)
|
||||
}
|
||||
|
||||
fun showControls(autoHideDelayMs: Long = DEFAULT_CONTROLS_AUTO_HIDE_MS) {
|
||||
applyControlsAutoHideCommand(
|
||||
controlsAutoHidePolicy.showControls(autoHideDelayMs)
|
||||
)
|
||||
}
|
||||
|
||||
fun toggleControlsVisibility() {
|
||||
applyControlsAutoHideCommand(
|
||||
controlsAutoHidePolicy.toggleControlsVisibility()
|
||||
)
|
||||
}
|
||||
|
||||
private fun applyControlsAutoHideCommand(command: ControlsAutoHideCommand) {
|
||||
_controlsVisible.value = controlsAutoHidePolicy.controlsVisible
|
||||
autoHideJob?.cancel()
|
||||
autoHideJob = null
|
||||
if (command !is ControlsAutoHideCommand.Schedule) return
|
||||
autoHideJob = viewModelScope.launch {
|
||||
delay(command.delayMs)
|
||||
controlsAutoHidePolicy.hideControls()
|
||||
_controlsVisible.value = controlsAutoHidePolicy.controlsVisible
|
||||
}
|
||||
}
|
||||
|
||||
fun next(autoHideDelayMs: Long = DEFAULT_CONTROLS_AUTO_HIDE_MS) {
|
||||
fun next() {
|
||||
seekByCollector.clear()
|
||||
playerManager.next()
|
||||
showControls(autoHideDelayMs)
|
||||
}
|
||||
|
||||
fun previous(autoHideDelayMs: Long = DEFAULT_CONTROLS_AUTO_HIDE_MS) {
|
||||
fun previous() {
|
||||
seekByCollector.clear()
|
||||
playerManager.previous()
|
||||
showControls(autoHideDelayMs)
|
||||
}
|
||||
|
||||
fun selectTrack(option: TrackOption) {
|
||||
@@ -269,7 +232,6 @@ class PlayerViewModel @Inject constructor(
|
||||
fun playQueueItem(id: String) {
|
||||
seekByCollector.clear()
|
||||
playerManager.play(id.toUuidOrNull() ?: return)
|
||||
showControls()
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
package hu.bbara.purefin.core.player.viewmodel
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ControlsAutoHidePolicyTest {
|
||||
|
||||
@Test
|
||||
fun activeBlockerCancelsScheduledAutoHide() {
|
||||
val policy = ControlsAutoHidePolicy(defaultDelayMs = 3_500L)
|
||||
|
||||
assertEquals(
|
||||
ControlsAutoHideCommand.Schedule(3_500L),
|
||||
policy.onPlaybackChanged(isPlaying = true)
|
||||
)
|
||||
assertEquals(
|
||||
ControlsAutoHideCommand.Cancel,
|
||||
policy.setBlocked(ControlsAutoHideBlocker.TRACK_PANEL, blocked = true)
|
||||
)
|
||||
assertTrue(policy.controlsVisible)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun playlistBlockerPreventsAutoHideUntilCleared() {
|
||||
val policy = ControlsAutoHidePolicy(defaultDelayMs = 3_500L)
|
||||
|
||||
policy.onPlaybackChanged(isPlaying = true)
|
||||
policy.showControls(delayMs = 5_000L)
|
||||
|
||||
assertEquals(
|
||||
ControlsAutoHideCommand.Cancel,
|
||||
policy.setBlocked(ControlsAutoHideBlocker.PLAYLIST, blocked = true)
|
||||
)
|
||||
assertEquals(
|
||||
ControlsAutoHideCommand.Schedule(5_000L),
|
||||
policy.setBlocked(ControlsAutoHideBlocker.PLAYLIST, blocked = false)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun trackPanelBlockerUsesRememberedDelayWhenRemoved() {
|
||||
val policy = ControlsAutoHidePolicy(defaultDelayMs = 3_500L)
|
||||
|
||||
policy.onPlaybackChanged(isPlaying = true)
|
||||
assertEquals(
|
||||
ControlsAutoHideCommand.Cancel,
|
||||
policy.setBlocked(ControlsAutoHideBlocker.TRACK_PANEL, blocked = true)
|
||||
)
|
||||
policy.showControls(delayMs = 5_000L)
|
||||
|
||||
assertEquals(
|
||||
ControlsAutoHideCommand.Schedule(5_000L),
|
||||
policy.setBlocked(ControlsAutoHideBlocker.TRACK_PANEL, blocked = false)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun autoHideResumesOnlyAfterLastBlockerIsCleared() {
|
||||
val policy = ControlsAutoHidePolicy(defaultDelayMs = 3_500L)
|
||||
|
||||
policy.onPlaybackChanged(isPlaying = true)
|
||||
policy.showControls(delayMs = 5_000L)
|
||||
policy.setBlocked(ControlsAutoHideBlocker.PLAYLIST, blocked = true)
|
||||
policy.setBlocked(ControlsAutoHideBlocker.TRACK_PANEL, blocked = true)
|
||||
|
||||
assertEquals(
|
||||
ControlsAutoHideCommand.Cancel,
|
||||
policy.setBlocked(ControlsAutoHideBlocker.PLAYLIST, blocked = false)
|
||||
)
|
||||
assertEquals(
|
||||
ControlsAutoHideCommand.Schedule(5_000L),
|
||||
policy.setBlocked(ControlsAutoHideBlocker.TRACK_PANEL, blocked = false)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun blockingStateMakesHiddenControlsVisibleAgain() {
|
||||
val policy = ControlsAutoHidePolicy(defaultDelayMs = 3_500L)
|
||||
|
||||
policy.onPlaybackChanged(isPlaying = true)
|
||||
policy.toggleControlsVisibility()
|
||||
|
||||
assertEquals(
|
||||
ControlsAutoHideCommand.Cancel,
|
||||
policy.setBlocked(ControlsAutoHideBlocker.PLAYLIST, blocked = true)
|
||||
)
|
||||
assertTrue(policy.controlsVisible)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,9 @@ import androidx.datastore.core.DataStore
|
||||
import hu.bbara.purefin.core.data.HomeRepository
|
||||
import hu.bbara.purefin.core.data.NetworkMonitor
|
||||
import hu.bbara.purefin.core.data.UserSessionRepository
|
||||
import hu.bbara.purefin.core.model.MediaUiModel
|
||||
import hu.bbara.purefin.core.model.MovieUiModel
|
||||
import hu.bbara.purefin.core.model.SeriesUiModel
|
||||
import hu.bbara.purefin.data.converter.toEpisode
|
||||
import hu.bbara.purefin.data.converter.toLibrary
|
||||
import hu.bbara.purefin.data.converter.toMovie
|
||||
@@ -24,6 +27,7 @@ import hu.bbara.purefin.data.offline.cache.toSeries
|
||||
import hu.bbara.purefin.model.Library
|
||||
import hu.bbara.purefin.model.LibraryKind
|
||||
import hu.bbara.purefin.model.Media
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -33,14 +37,14 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.concurrent.atomics.AtomicBoolean
|
||||
import kotlin.concurrent.atomics.ExperimentalAtomicApi
|
||||
import timber.log.Timber
|
||||
|
||||
@Singleton
|
||||
class InMemoryAppContentRepository @Inject constructor(
|
||||
@@ -51,6 +55,7 @@ class InMemoryAppContentRepository @Inject constructor(
|
||||
private val networkMonitor: NetworkMonitor,
|
||||
) : HomeRepository {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private var cacheLoadJob: Job? = null
|
||||
private var refreshJob: Job? = null
|
||||
|
||||
@OptIn(ExperimentalAtomicApi::class)
|
||||
@@ -71,10 +76,19 @@ class InMemoryAppContentRepository @Inject constructor(
|
||||
private val latestLibraryContentState = MutableStateFlow<Map<UUID, List<Media>>>(emptyMap())
|
||||
override val latestLibraryContent: StateFlow<Map<UUID, List<Media>>> = latestLibraryContentState.asStateFlow()
|
||||
|
||||
// dateLastMediaAdded values captured at the end of the last successful
|
||||
// refresh. Used to short-circuit per-library /Items/Latest calls when
|
||||
// the timestamp hasn't moved. Persisted via HomeCache.
|
||||
private val cachedLibraryDateLastMediaAdded = mutableMapOf<UUID, String>()
|
||||
|
||||
// dateLastMediaAdded values observed during the CURRENT refresh's
|
||||
// /UserViews response. Read by loadLatestLibraryContent to decide
|
||||
// which libraries need a /Items/Latest call, then merged into
|
||||
// cachedLibraryDateLastMediaAdded and persisted at the end.
|
||||
private var currentLibraryDateLastMediaAdded: Map<UUID, String> = emptyMap()
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
ensureReady()
|
||||
}
|
||||
ensureReady()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalAtomicApi::class)
|
||||
@@ -83,17 +97,23 @@ class InMemoryAppContentRepository @Inject constructor(
|
||||
return
|
||||
}
|
||||
Timber.tag(TAG).d("Initializing home repository")
|
||||
scope.launch { loadHomeCache() }
|
||||
scope.launch { refreshHomeData() }
|
||||
val loadJob = scope.launch { loadHomeCache() }
|
||||
cacheLoadJob = loadJob
|
||||
scope.launch {
|
||||
loadJob.join()
|
||||
refreshHomeData()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun refreshHomeData() {
|
||||
cacheLoadJob?.join()
|
||||
val job = synchronized(this) {
|
||||
refreshJob?.takeIf { it.isActive } ?: scope.launch {
|
||||
runCatching {
|
||||
val snapshot = snapshotHomeContent()
|
||||
try {
|
||||
Timber.tag(TAG).d("Refreshing home data")
|
||||
if (!networkMonitor.isOnline.first()) {
|
||||
return@runCatching
|
||||
return@launch
|
||||
}
|
||||
loadLibraries()
|
||||
loadSuggestions()
|
||||
@@ -102,8 +122,15 @@ class InMemoryAppContentRepository @Inject constructor(
|
||||
loadLatestLibraryContent()
|
||||
Timber.tag(TAG).d("Home refresh successful")
|
||||
persistHomeCache()
|
||||
}.onFailure { error ->
|
||||
} catch (error: CancellationException) {
|
||||
throw error
|
||||
} catch (error: HomeRefreshFailedException) {
|
||||
restoreHomeContent(snapshot)
|
||||
Timber.tag(TAG).w(error.cause, "Home refresh failed; keeping cached content")
|
||||
} catch (error: Exception) {
|
||||
restoreHomeContent(snapshot)
|
||||
Timber.tag(TAG).w(error, "Home refresh failed; keeping cached content")
|
||||
networkMonitor.checkConnection()
|
||||
}
|
||||
}.also { refreshJob = it }
|
||||
}
|
||||
@@ -113,6 +140,14 @@ class InMemoryAppContentRepository @Inject constructor(
|
||||
private suspend fun loadHomeCache() {
|
||||
Timber.tag(TAG).d("Loading home cache")
|
||||
val cache = homeCacheDataStore.data.first()
|
||||
// Hydrate the dateLastMediaAdded map from disk before the first
|
||||
// refresh runs, so the per-library short-circuit has a baseline.
|
||||
cachedLibraryDateLastMediaAdded.clear()
|
||||
cache.libraryDateLastMediaAdded.forEach { (key, date) ->
|
||||
runCatching { UUID.fromString(key) }.getOrNull()?.let { uuid ->
|
||||
cachedLibraryDateLastMediaAdded[uuid] = date
|
||||
}
|
||||
}
|
||||
if (cache.libraries.isNotEmpty()) {
|
||||
val libraries = cache.libraries.mapNotNull { it.toLibrary() }
|
||||
librariesState.value = libraries
|
||||
@@ -155,6 +190,12 @@ class InMemoryAppContentRepository @Inject constructor(
|
||||
val movies = onlineMediaRepository.movies.value
|
||||
val series = onlineMediaRepository.series.value
|
||||
val episodes = onlineMediaRepository.episodes.value
|
||||
// Persist this refresh's dateLastMediaAdded as the new baseline
|
||||
// for the next refresh's per-library short-circuit.
|
||||
cachedLibraryDateLastMediaAdded.clear()
|
||||
currentLibraryDateLastMediaAdded.forEach { (id, date) ->
|
||||
cachedLibraryDateLastMediaAdded[id] = date
|
||||
}
|
||||
val cache = HomeCache(
|
||||
suggestions = suggestionsState.value.map { it.toCachedItem() },
|
||||
continueWatching = continueWatchingState.value.map { it.toCachedItem() },
|
||||
@@ -163,6 +204,7 @@ class InMemoryAppContentRepository @Inject constructor(
|
||||
uuid.toString() to items.map { it.toCachedItem() }
|
||||
}.toMap(),
|
||||
libraries = librariesState.value.map { it.toCachedLibrary() },
|
||||
libraryDateLastMediaAdded = cachedLibraryDateLastMediaAdded.mapKeys { it.key.toString() },
|
||||
movies = referencedMediaIds.movieIds.mapNotNull { movies[it] }.map { it.toCachedMovie() },
|
||||
series = referencedMediaIds.seriesIds.mapNotNull { series[it] }.map { it.toCachedSeries() },
|
||||
episodes = referencedMediaIds.episodeIds.mapNotNull { episodes[it] }.map { it.toCachedEpisode() },
|
||||
@@ -200,49 +242,90 @@ class InMemoryAppContentRepository @Inject constructor(
|
||||
private suspend fun loadLibraries() {
|
||||
val librariesItem = runCatching { jellyfinApiClient.getLibraries() }
|
||||
.getOrElse { error ->
|
||||
Timber.tag(TAG).w(error, "Unable to load libraries")
|
||||
return
|
||||
handleRefreshFailure(error, "Unable to load libraries")
|
||||
}
|
||||
|
||||
// Build the new library list in a single pass, so collectors of
|
||||
// `librariesState` only ever see the final value. When /UserViews
|
||||
// returns 304 we already have the BaseItemDto list in memory, so
|
||||
// we just rebuild from it. Otherwise we have the new DTOs.
|
||||
val filledLibraries: List<Library> = if (librariesItem == null) {
|
||||
// ETag 304 on /UserViews — re-fetch the count per library
|
||||
// against the existing in-memory state. The size fallback
|
||||
// (`?: library.size`) handles a 304 on /Items too, so a
|
||||
// 304 on both endpoints leaves Library.size untouched.
|
||||
librariesState.value.map { library ->
|
||||
val count = jellyfinApiClient.getLibraryItemCount(library.id) ?: library.size
|
||||
when (library.type) {
|
||||
LibraryKind.MOVIES -> library.copy(movies = emptyList(), size = count)
|
||||
LibraryKind.SERIES -> library.copy(series = emptyList(), size = count)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Capture the server-reported "last media added" timestamp
|
||||
// per library for this refresh. loadLatestLibraryContent
|
||||
// reads this map to decide which libraries need a
|
||||
// /Items/Latest call, and persistHomeCache writes it to
|
||||
// HomeCache as the new "cached" baseline.
|
||||
currentLibraryDateLastMediaAdded = librariesItem
|
||||
.mapNotNull { dto ->
|
||||
dto.dateLastMediaAdded?.let { dto.id to it.toString() }
|
||||
}
|
||||
.toMap()
|
||||
|
||||
// Count-only refresh: ask the server for the number of items
|
||||
// in each library instead of enumerating them. The home
|
||||
// dashboard only uses Library.size for the library cards;
|
||||
// the full movies/series lists are fetched on demand by the
|
||||
// library detail screen via loadLibraryContent, and on the
|
||||
// home rows by loadSuggestions / loadContinueWatching /
|
||||
// loadLatestLibraryContent.
|
||||
librariesItem.map { dto ->
|
||||
val library = dto.toLibrary(serverUrl())
|
||||
val count = jellyfinApiClient.getLibraryItemCount(library.id) ?: library.size
|
||||
when (library.type) {
|
||||
LibraryKind.MOVIES -> library.copy(movies = emptyList(), size = count)
|
||||
LibraryKind.SERIES -> library.copy(series = emptyList(), size = count)
|
||||
}
|
||||
}
|
||||
val filteredLibraries = librariesItem.filter {
|
||||
it.collectionType == CollectionType.MOVIES || it.collectionType == CollectionType.TVSHOWS
|
||||
}
|
||||
val emptyLibraries = filteredLibraries.map { it.toLibrary(serverUrl()) }
|
||||
librariesState.value = emptyLibraries
|
||||
|
||||
val filledLibraries = emptyLibraries.map { loadLibrary(it) }
|
||||
librariesState.value = filledLibraries
|
||||
|
||||
val movies = filledLibraries.filter { it.type == LibraryKind.MOVIES }.flatMap { it.movies.orEmpty() }
|
||||
onlineMediaRepository.upsertMovies(movies)
|
||||
|
||||
val series = filledLibraries.filter { it.type == LibraryKind.SERIES }.flatMap { it.series.orEmpty() }
|
||||
onlineMediaRepository.upsertSeries(series)
|
||||
}
|
||||
|
||||
private suspend fun loadLibrary(library: Library): Library {
|
||||
val contentItem = runCatching { jellyfinApiClient.getLibraryContent(library.id) }
|
||||
.getOrElse { error ->
|
||||
Timber.tag(TAG).w(error, "Unable to load library ${library.id}")
|
||||
return library
|
||||
}
|
||||
override suspend fun loadLibraryContent(libraryId: UUID): List<MediaUiModel>? {
|
||||
val library = librariesState.value.find { it.id == libraryId } ?: return emptyList()
|
||||
// ETag hit — return null so the caller can keep its previously
|
||||
// fetched copy. The library detail viewmodel uses this signal to
|
||||
// preserve its in-memory cache across re-selections.
|
||||
val items = jellyfinApiClient.getLibraryContent(libraryId) ?: return null
|
||||
val url = serverUrl()
|
||||
return when (library.type) {
|
||||
LibraryKind.MOVIES -> library.copy(
|
||||
movies = contentItem.map { it.toMovie(serverUrl()) },
|
||||
size = contentItem.size,
|
||||
)
|
||||
LibraryKind.SERIES -> library.copy(
|
||||
series = contentItem.map { it.toSeries(serverUrl()) },
|
||||
size = contentItem.size,
|
||||
)
|
||||
LibraryKind.MOVIES -> items.map { MovieUiModel(it.toMovie(url)) }
|
||||
LibraryKind.SERIES -> items.map { SeriesUiModel(it.toSeries(url)) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadSuggestions() {
|
||||
val url = serverUrl()
|
||||
// Head-first fetch: ask the server for just the first 2 items and
|
||||
// compare to the cached head. If the head is unchanged, the rest
|
||||
// of the row is also unchanged (the home screen shows the head
|
||||
// prominently and only rotates when new content arrives at the
|
||||
// top), so we can skip the full request entirely.
|
||||
val headItems = runCatching { jellyfinApiClient.getSuggestionsHead() }
|
||||
.getOrElse { error ->
|
||||
handleRefreshFailure(error, "Unable to load suggestions head")
|
||||
}
|
||||
if (headItems == null) return
|
||||
if (headMatches(headItems, suggestionsState.value)) return
|
||||
|
||||
// Head changed (or first refresh) — fetch the full list.
|
||||
val suggestionsItems = runCatching { jellyfinApiClient.getSuggestions() }
|
||||
.getOrElse { error ->
|
||||
Timber.tag(TAG).w(error, "Unable to load suggestions")
|
||||
return
|
||||
handleRefreshFailure(error, "Unable to load suggestions")
|
||||
}
|
||||
if (suggestionsItems == null) return
|
||||
|
||||
suggestionsState.value = suggestionsItems.mapNotNull { item ->
|
||||
when (item.type) {
|
||||
BaseItemKind.MOVIE -> Media.MovieMedia(movieId = item.id)
|
||||
@@ -251,19 +334,31 @@ class InMemoryAppContentRepository @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
// Upsert full details so the home viewmodel can look up each item.
|
||||
suggestionsItems.forEach { item ->
|
||||
if (item.type == BaseItemKind.EPISODE) {
|
||||
onlineMediaRepository.upsertEpisodes(listOf(item.toEpisode(serverUrl())))
|
||||
when (item.type) {
|
||||
BaseItemKind.MOVIE -> onlineMediaRepository.upsertMovies(listOf(item.toMovie(url)))
|
||||
BaseItemKind.EPISODE -> onlineMediaRepository.upsertEpisodes(listOf(item.toEpisode(url)))
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadContinueWatching() {
|
||||
val url = serverUrl()
|
||||
val headItems = runCatching { jellyfinApiClient.getContinueWatchingHead() }
|
||||
.getOrElse { error ->
|
||||
handleRefreshFailure(error, "Unable to load continue watching head")
|
||||
}
|
||||
if (headItems == null) return
|
||||
if (headMatches(headItems, continueWatchingState.value)) return
|
||||
|
||||
val continueWatchingItems = runCatching { jellyfinApiClient.getContinueWatching() }
|
||||
.getOrElse { error ->
|
||||
Timber.tag(TAG).w(error, "Unable to load continue watching")
|
||||
return
|
||||
handleRefreshFailure(error, "Unable to load continue watching")
|
||||
}
|
||||
if (continueWatchingItems == null) return
|
||||
|
||||
continueWatchingState.value = continueWatchingItems.mapNotNull { item ->
|
||||
when (item.type) {
|
||||
BaseItemKind.MOVIE -> Media.MovieMedia(movieId = item.id)
|
||||
@@ -273,74 +368,159 @@ class InMemoryAppContentRepository @Inject constructor(
|
||||
}
|
||||
|
||||
continueWatchingItems.forEach { item ->
|
||||
if (item.type == BaseItemKind.EPISODE) {
|
||||
onlineMediaRepository.upsertEpisodes(listOf(item.toEpisode(serverUrl())))
|
||||
when (item.type) {
|
||||
BaseItemKind.MOVIE -> onlineMediaRepository.upsertMovies(listOf(item.toMovie(url)))
|
||||
BaseItemKind.EPISODE -> onlineMediaRepository.upsertEpisodes(listOf(item.toEpisode(url)))
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadNextUp() {
|
||||
val url = serverUrl()
|
||||
val headItems = runCatching { jellyfinApiClient.getNextUpHead() }
|
||||
.getOrElse { error ->
|
||||
handleRefreshFailure(error, "Unable to load next up head")
|
||||
}
|
||||
if (headItems == null) return
|
||||
if (headMatches(headItems, nextUpState.value)) return
|
||||
|
||||
val nextUpItems = runCatching { jellyfinApiClient.getNextUpEpisodes() }
|
||||
.getOrElse { error ->
|
||||
Timber.tag(TAG).w(error, "Unable to load next up")
|
||||
return
|
||||
handleRefreshFailure(error, "Unable to load next up")
|
||||
}
|
||||
if (nextUpItems == null) return
|
||||
|
||||
nextUpState.value = nextUpItems.map { item ->
|
||||
Media.EpisodeMedia(episodeId = item.id, seriesId = item.seriesId!!)
|
||||
}
|
||||
|
||||
nextUpItems.forEach { item ->
|
||||
onlineMediaRepository.upsertEpisodes(listOf(item.toEpisode(serverUrl())))
|
||||
onlineMediaRepository.upsertEpisodes(listOf(item.toEpisode(url)))
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadLatestLibraryContent() {
|
||||
val librariesItem = runCatching { jellyfinApiClient.getLibraries() }
|
||||
.getOrElse { error ->
|
||||
Timber.tag(TAG).w(error, "Unable to load latest library content")
|
||||
return
|
||||
}
|
||||
val filteredLibraries = librariesItem.filter {
|
||||
it.collectionType == CollectionType.MOVIES || it.collectionType == CollectionType.TVSHOWS
|
||||
}
|
||||
// Reuse the libraries already loaded by loadLibraries() so we don't refetch
|
||||
// the same /Users/{userId}/Views response a second time per refresh.
|
||||
val filteredLibraries = librariesState.value
|
||||
val url = serverUrl()
|
||||
val latestLibraryContents = filteredLibraries.associate { library ->
|
||||
val latestFromLibrary = runCatching { jellyfinApiClient.getLatestFromLibrary(library.id) }
|
||||
.getOrElse { error ->
|
||||
Timber.tag(TAG).w(error, "Unable to load latest items for library ${library.id}")
|
||||
emptyList()
|
||||
}
|
||||
library.id to when (library.collectionType) {
|
||||
CollectionType.MOVIES -> latestFromLibrary.map {
|
||||
val movie = it.toMovie(serverUrl())
|
||||
Media.MovieMedia(movieId = movie.id)
|
||||
}
|
||||
CollectionType.TVSHOWS -> latestFromLibrary.map {
|
||||
when (it.type) {
|
||||
BaseItemKind.SERIES -> {
|
||||
val series = it.toSeries(serverUrl())
|
||||
Media.SeriesMedia(seriesId = series.id)
|
||||
// Layer 1: skip when the server-reported "last media added"
|
||||
// timestamp is unchanged since the last refresh. This is the
|
||||
// cheapest check (no request) and handles the common "no new
|
||||
// content" case.
|
||||
val cachedDate = cachedLibraryDateLastMediaAdded[library.id]
|
||||
val currentDate = currentLibraryDateLastMediaAdded[library.id]
|
||||
if (cachedDate != null && cachedDate == currentDate) {
|
||||
library.id to (latestLibraryContentState.value[library.id] ?: emptyList())
|
||||
} else {
|
||||
// Layer 2: head-only fetch. If the first 2 items of the
|
||||
// latest row are the same as the cached head, the rest of
|
||||
// the row is also unchanged (the home row only rotates at
|
||||
// the top) and we can skip the full request.
|
||||
val headDtos = runCatching { jellyfinApiClient.getLatestFromLibraryHead(library.id) }
|
||||
.getOrElse { error ->
|
||||
handleRefreshFailure(error, "Unable to load latest head for library ${library.id}")
|
||||
}
|
||||
val cachedSlice = latestLibraryContentState.value[library.id].orEmpty()
|
||||
if (headDtos == null || headMatches(headDtos, cachedSlice)) {
|
||||
library.id to cachedSlice
|
||||
} else {
|
||||
// Layer 3: head changed — fetch the full row.
|
||||
val latestFromLibrary = runCatching { jellyfinApiClient.getLatestFromLibrary(library.id) }
|
||||
.getOrElse { error ->
|
||||
handleRefreshFailure(error, "Unable to load latest items for library ${library.id}")
|
||||
}
|
||||
BaseItemKind.SEASON -> {
|
||||
val season = it.toSeason()
|
||||
Media.SeasonMedia(seasonId = season.id, seriesId = season.seriesId)
|
||||
if (latestFromLibrary == null) {
|
||||
library.id to cachedSlice
|
||||
} else {
|
||||
val media = when (library.type) {
|
||||
LibraryKind.MOVIES -> latestFromLibrary.map {
|
||||
val movie = it.toMovie(url)
|
||||
onlineMediaRepository.upsertMovies(listOf(movie))
|
||||
Media.MovieMedia(movieId = movie.id)
|
||||
}
|
||||
LibraryKind.SERIES -> latestFromLibrary.map { dto ->
|
||||
when (dto.type) {
|
||||
BaseItemKind.SERIES -> {
|
||||
val series = dto.toSeries(url)
|
||||
onlineMediaRepository.upsertSeries(listOf(series))
|
||||
Media.SeriesMedia(seriesId = series.id)
|
||||
}
|
||||
BaseItemKind.SEASON -> {
|
||||
val season = dto.toSeason()
|
||||
Media.SeasonMedia(seasonId = season.id, seriesId = season.seriesId)
|
||||
}
|
||||
BaseItemKind.EPISODE -> {
|
||||
val episode = dto.toEpisode(url)
|
||||
onlineMediaRepository.upsertEpisodes(listOf(episode))
|
||||
Media.EpisodeMedia(episodeId = episode.id, seriesId = episode.seriesId)
|
||||
}
|
||||
else -> throw UnsupportedOperationException("Unsupported item type: ${dto.type}")
|
||||
}
|
||||
}
|
||||
}
|
||||
BaseItemKind.EPISODE -> {
|
||||
val episode = it.toEpisode(serverUrl())
|
||||
Media.EpisodeMedia(episodeId = episode.id, seriesId = episode.seriesId)
|
||||
}
|
||||
else -> throw UnsupportedOperationException("Unsupported item type: ${it.type}")
|
||||
library.id to media
|
||||
}
|
||||
}
|
||||
else -> throw UnsupportedOperationException("Unsupported library type: ${library.collectionType}")
|
||||
}
|
||||
}
|
||||
latestLibraryContentState.value = latestLibraryContents
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the first [JellyfinApiClient.HEAD_LIMIT] IDs of the
|
||||
* freshly fetched [headDtos] match the first
|
||||
* [JellyfinApiClient.HEAD_LIMIT] IDs of the cached [cachedMedia]. Used
|
||||
* by the home row refreshers to short-circuit the full request when
|
||||
* the head of the row is unchanged.
|
||||
*
|
||||
* Order-sensitive: the comparison is positional. The Jellyfin server
|
||||
* can reorder rows (e.g., Suggestions reorders on a relevance
|
||||
* recompute even when the item set is unchanged), which will report
|
||||
* a false "head changed" and pay for the full request. The reverse
|
||||
* false negative — head order unchanged but a mid-list item rotated —
|
||||
* is a known limitation of the head-diff heuristic.
|
||||
*/
|
||||
private fun headMatches(
|
||||
headDtos: List<BaseItemDto>,
|
||||
cachedMedia: List<Media>,
|
||||
): Boolean {
|
||||
val headIds = headDtos.take(JellyfinApiClient.HEAD_LIMIT).map { it.id }
|
||||
val cachedIds = cachedMedia.take(JellyfinApiClient.HEAD_LIMIT).map { it.id }
|
||||
return headIds == cachedIds
|
||||
}
|
||||
|
||||
private suspend fun serverUrl(): String {
|
||||
return userSessionRepository.serverUrl.first()
|
||||
}
|
||||
|
||||
private fun snapshotHomeContent(): HomeContentSnapshot = HomeContentSnapshot(
|
||||
libraries = librariesState.value,
|
||||
suggestions = suggestionsState.value,
|
||||
continueWatching = continueWatchingState.value,
|
||||
nextUp = nextUpState.value,
|
||||
latestLibraryContent = latestLibraryContentState.value,
|
||||
)
|
||||
|
||||
private fun restoreHomeContent(snapshot: HomeContentSnapshot) {
|
||||
librariesState.value = snapshot.libraries
|
||||
suggestionsState.value = snapshot.suggestions
|
||||
continueWatchingState.value = snapshot.continueWatching
|
||||
nextUpState.value = snapshot.nextUp
|
||||
latestLibraryContentState.value = snapshot.latestLibraryContent
|
||||
}
|
||||
|
||||
private suspend fun handleRefreshFailure(error: Throwable, message: String): Nothing {
|
||||
if (error is CancellationException) {
|
||||
throw error
|
||||
}
|
||||
Timber.tag(TAG).w(error, message)
|
||||
networkMonitor.checkConnection()
|
||||
throw HomeRefreshFailedException(error)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "InMemoryAppContentRepo"
|
||||
}
|
||||
@@ -351,3 +531,13 @@ private data class ReferencedHomeMediaIds(
|
||||
val seriesIds: Set<UUID>,
|
||||
val episodeIds: Set<UUID>,
|
||||
)
|
||||
|
||||
private data class HomeContentSnapshot(
|
||||
val libraries: List<Library>,
|
||||
val suggestions: List<Media>,
|
||||
val continueWatching: List<Media>,
|
||||
val nextUp: List<Media>,
|
||||
val latestLibraryContent: Map<UUID, List<Media>>,
|
||||
)
|
||||
|
||||
private class HomeRefreshFailedException(cause: Throwable) : RuntimeException(cause)
|
||||
@@ -167,12 +167,18 @@ class InMemoryLocalMediaRepository @Inject constructor(
|
||||
override suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long) {
|
||||
if (durationMs <= 0) return
|
||||
val progressPercent = (positionMs.toDouble() / durationMs.toDouble()) * 100.0
|
||||
val watched = progressPercent >= 90.0
|
||||
updateWatchProgressPercent(mediaId, progressPercent)
|
||||
}
|
||||
|
||||
override suspend fun updateWatchProgressPercent(mediaId: UUID, progressPercent: Double) {
|
||||
if (progressPercent.isNaN()) return
|
||||
val normalizedProgressPercent = progressPercent.coerceIn(0.0, 100.0)
|
||||
val watched = normalizedProgressPercent >= 90.0
|
||||
|
||||
if (moviesState.value.containsKey(mediaId)) {
|
||||
moviesState.update { current ->
|
||||
val movie = current[mediaId] ?: return@update current
|
||||
current + (mediaId to movie.copy(progress = progressPercent, watched = watched))
|
||||
current + (mediaId to movie.copy(progress = normalizedProgressPercent, watched = watched))
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -180,7 +186,7 @@ class InMemoryLocalMediaRepository @Inject constructor(
|
||||
var updatedEpisode: Episode? = null
|
||||
episodesState.update { current ->
|
||||
val episode = current[mediaId] ?: return@update current
|
||||
val updated = episode.copy(progress = progressPercent, watched = watched)
|
||||
val updated = episode.copy(progress = normalizedProgressPercent, watched = watched)
|
||||
updatedEpisode = updated
|
||||
current + (mediaId to updated)
|
||||
}
|
||||
@@ -208,6 +214,14 @@ class InMemoryLocalMediaRepository @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun updatePlaybackPosition(
|
||||
mediaId: UUID,
|
||||
playbackPositionTicks: Long,
|
||||
runtimeTicks: Long,
|
||||
) {
|
||||
// Server-side operation — not persisted locally
|
||||
}
|
||||
|
||||
private fun updateLoadedSeriesEpisode(updatedEpisode: Episode) {
|
||||
seriesState.update { current ->
|
||||
val series = current[updatedEpisode.seriesId] ?: return@update current
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package hu.bbara.purefin.data.catalog
|
||||
|
||||
import hu.bbara.purefin.core.data.LocalMediaRepository
|
||||
import hu.bbara.purefin.core.data.OfflineMediaManager
|
||||
import hu.bbara.purefin.data.offline.room.offline.OfflineRoomMediaLocalDataSource
|
||||
import hu.bbara.purefin.model.Episode
|
||||
import hu.bbara.purefin.model.Movie
|
||||
import hu.bbara.purefin.model.Season
|
||||
import hu.bbara.purefin.model.Series
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -20,7 +22,7 @@ import javax.inject.Singleton
|
||||
@Singleton
|
||||
class OfflineLocalMediaRepository @Inject constructor(
|
||||
private val localDataSource: OfflineRoomMediaLocalDataSource,
|
||||
) : LocalMediaRepository {
|
||||
) : LocalMediaRepository, OfflineMediaManager {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
override val movies: StateFlow<Map<UUID, Movie>> = localDataSource.moviesFlow
|
||||
@@ -55,11 +57,61 @@ class OfflineLocalMediaRepository @Inject constructor(
|
||||
override suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long) {
|
||||
if (durationMs <= 0) return
|
||||
val progressPercent = (positionMs.toDouble() / durationMs.toDouble()) * 100.0
|
||||
val watched = progressPercent >= 90.0
|
||||
localDataSource.updateWatchProgress(mediaId, progressPercent, watched)
|
||||
updateWatchProgressPercent(mediaId, progressPercent)
|
||||
}
|
||||
|
||||
override suspend fun updateWatchProgressPercent(mediaId: UUID, progressPercent: Double) {
|
||||
if (progressPercent.isNaN()) return
|
||||
val normalizedProgressPercent = progressPercent.coerceIn(0.0, 100.0)
|
||||
val watched = normalizedProgressPercent >= 90.0
|
||||
localDataSource.updateWatchProgress(mediaId, normalizedProgressPercent, watched)
|
||||
}
|
||||
|
||||
override suspend fun markAsWatched(mediaId: UUID, watched: Boolean) {
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
override suspend fun updatePlaybackPosition(
|
||||
mediaId: UUID,
|
||||
playbackPositionTicks: Long,
|
||||
runtimeTicks: Long,
|
||||
) {
|
||||
// Server-side operation — not persisted offline
|
||||
}
|
||||
|
||||
override suspend fun saveMovies(movies: List<Movie>) {
|
||||
localDataSource.saveMovies(movies)
|
||||
}
|
||||
|
||||
override suspend fun saveSeries(series: List<Series>) {
|
||||
localDataSource.saveSeries(series)
|
||||
}
|
||||
|
||||
override suspend fun saveSeason(season: Season) {
|
||||
localDataSource.saveSeason(season)
|
||||
}
|
||||
|
||||
override suspend fun saveEpisode(episode: Episode) {
|
||||
localDataSource.saveEpisode(episode)
|
||||
}
|
||||
|
||||
override suspend fun getSeriesBasic(seriesId: UUID): Series? {
|
||||
return localDataSource.getSeriesBasic(seriesId)
|
||||
}
|
||||
|
||||
override suspend fun getSeason(seasonId: UUID): Season? {
|
||||
return localDataSource.getSeason(seasonId)
|
||||
}
|
||||
|
||||
override suspend fun deleteMovie(movieId: UUID) {
|
||||
localDataSource.deleteMovie(movieId)
|
||||
}
|
||||
|
||||
override suspend fun deleteEpisodeAndCleanup(episodeId: UUID) {
|
||||
localDataSource.deleteEpisodeAndCleanup(episodeId)
|
||||
}
|
||||
|
||||
override suspend fun getEpisodesBySeries(seriesId: UUID): List<Episode> {
|
||||
return localDataSource.getEpisodesBySeries(seriesId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,54 +7,48 @@ import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import hu.bbara.purefin.core.data.NetworkMonitor
|
||||
import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import java.net.ConnectException
|
||||
import java.net.NoRouteToHostException
|
||||
import java.net.SocketTimeoutException
|
||||
import java.net.UnknownHostException
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class ConnectivityNetworkMonitor @Inject constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
@ApplicationContext private val context: Context,
|
||||
private val jellyfinApiClient: JellyfinApiClient,
|
||||
) : NetworkMonitor {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val connectivityManager = context.getSystemService(ConnectivityManager::class.java)
|
||||
private val isAndroidConnected = MutableStateFlow(connectivityManager.isCurrentlyConnected())
|
||||
private val isServerReachable = MutableStateFlow(true)
|
||||
|
||||
private val isDeviceOnline: Flow<Boolean> = callbackFlow {
|
||||
var wasDeviceOnline = connectivityManager.isCurrentlyConnected()
|
||||
override val isOnline: Flow<Boolean> = combine(
|
||||
isAndroidConnected,
|
||||
isServerReachable,
|
||||
) { androidConnected, serverReachable ->
|
||||
androidConnected && serverReachable
|
||||
}.distinctUntilChanged()
|
||||
|
||||
init {
|
||||
val callback = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onCapabilitiesChanged(
|
||||
network: Network,
|
||||
networkCapabilities: NetworkCapabilities
|
||||
) {
|
||||
val isCurrentlyConnected = connectivityManager.isCurrentlyConnected()
|
||||
if (isCurrentlyConnected && !wasDeviceOnline) {
|
||||
isServerReachable.value = true
|
||||
}
|
||||
wasDeviceOnline = isCurrentlyConnected
|
||||
trySend(isCurrentlyConnected)
|
||||
override fun onAvailable(network: Network) {
|
||||
isAndroidConnected.value = true
|
||||
scope.launch { updateServerReachability() }
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
val isCurrentlyConnected = connectivityManager.isCurrentlyConnected()
|
||||
wasDeviceOnline = isCurrentlyConnected
|
||||
trySend(isCurrentlyConnected)
|
||||
isAndroidConnected.value = connectivityManager.isCurrentlyConnected()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,51 +56,42 @@ class ConnectivityNetworkMonitor @Inject constructor(
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.build()
|
||||
|
||||
trySend(wasDeviceOnline)
|
||||
connectivityManager.registerNetworkCallback(request, callback)
|
||||
awaitClose { connectivityManager.unregisterNetworkCallback(callback) }
|
||||
}.distinctUntilChanged()
|
||||
|
||||
override val isOnline: StateFlow<Boolean> = combine(
|
||||
isDeviceOnline,
|
||||
isServerReachable
|
||||
) { isDeviceOnline, isServerReachable ->
|
||||
isDeviceOnline && isServerReachable
|
||||
}.distinctUntilChanged()
|
||||
.stateIn(
|
||||
scope = scope,
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = connectivityManager.isCurrentlyConnected() && isServerReachable.value
|
||||
)
|
||||
|
||||
override fun reportRequestSucceeded() {
|
||||
isServerReachable.value = true
|
||||
}
|
||||
|
||||
override fun reportRequestFailed(error: Throwable) {
|
||||
if (error.isConnectivityFailure()) {
|
||||
override suspend fun checkConnection(): Boolean {
|
||||
val androidConnected = connectivityManager.isCurrentlyConnected()
|
||||
isAndroidConnected.value = androidConnected
|
||||
if (!androidConnected) {
|
||||
isServerReachable.value = false
|
||||
return false
|
||||
}
|
||||
return updateServerReachability()
|
||||
}
|
||||
|
||||
private suspend fun updateServerReachability(): Boolean {
|
||||
val reachable = try {
|
||||
withTimeoutOrNull(SERVER_CHECK_TIMEOUT_MS) {
|
||||
jellyfinApiClient.probeServer()
|
||||
} ?: false
|
||||
} catch (error: CancellationException) {
|
||||
throw error
|
||||
} catch (error: Exception) {
|
||||
Timber.tag(TAG).w(error, "Jellyfin server reachability check failed")
|
||||
false
|
||||
}
|
||||
isServerReachable.value = reachable
|
||||
return reachable
|
||||
}
|
||||
|
||||
private fun ConnectivityManager.isCurrentlyConnected(): Boolean {
|
||||
val network = activeNetwork ?: return false
|
||||
val caps = getNetworkCapabilities(network) ?: return false
|
||||
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
|
||||
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
||||
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
}
|
||||
|
||||
private fun Throwable.isConnectivityFailure(): Boolean {
|
||||
var current: Throwable? = this
|
||||
while (current != null) {
|
||||
when (current) {
|
||||
is UnknownHostException,
|
||||
is ConnectException,
|
||||
is NoRouteToHostException,
|
||||
is SocketTimeoutException -> return true
|
||||
}
|
||||
current = current.cause
|
||||
}
|
||||
return false
|
||||
private companion object {
|
||||
private const val TAG = "ConnectivityNetworkMonitor"
|
||||
private const val SERVER_CHECK_TIMEOUT_MS = 5_000L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,9 @@ import androidx.media3.common.util.UnstableApi
|
||||
import hu.bbara.purefin.core.Offline
|
||||
import hu.bbara.purefin.core.data.LocalMediaRepository
|
||||
import hu.bbara.purefin.core.data.NetworkMonitor
|
||||
import hu.bbara.purefin.core.data.OfflineMediaManager
|
||||
import hu.bbara.purefin.core.data.PlayableMediaRepository
|
||||
import hu.bbara.purefin.core.data.PlaybackMediaItemTag
|
||||
import hu.bbara.purefin.core.data.PlaybackReportContext
|
||||
import hu.bbara.purefin.core.data.UserSessionRepository
|
||||
import hu.bbara.purefin.core.download.MediaDownloadController
|
||||
@@ -17,8 +19,7 @@ import hu.bbara.purefin.core.image.ImageUrlBuilder
|
||||
import hu.bbara.purefin.core.player.preference.TrackPreferencesRepository
|
||||
import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient
|
||||
import hu.bbara.purefin.data.jellyfin.playback.JellyfinPlaybackResolver
|
||||
import hu.bbara.purefin.data.jellyfin.playback.PlaybackDecision
|
||||
import hu.bbara.purefin.data.jellyfin.playback.playbackCustomCacheKey
|
||||
import hu.bbara.purefin.data.jellyfin.playback.PlaybackSource
|
||||
import hu.bbara.purefin.model.Episode
|
||||
import hu.bbara.purefin.model.MediaSegment
|
||||
import hu.bbara.purefin.model.PlayableMedia
|
||||
@@ -37,6 +38,7 @@ import org.jellyfin.sdk.model.api.MediaSegmentType.RECAP
|
||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -49,6 +51,7 @@ class DefaultPlayableMediaRepository @Inject constructor(
|
||||
private val mediaDownloadController: MediaDownloadController,
|
||||
private val networkMonitor: NetworkMonitor,
|
||||
@param:Offline private val offlineMediaRepository: LocalMediaRepository,
|
||||
private val offlineMediaManager: OfflineMediaManager,
|
||||
) : PlayableMediaRepository {
|
||||
|
||||
override suspend fun getPlayableMedia(mediaId: UUID): PlayableMedia? = withContext(Dispatchers.IO) {
|
||||
@@ -67,18 +70,31 @@ class DefaultPlayableMediaRepository @Inject constructor(
|
||||
return getOfflineDownloadedPlayableMedia(mediaId, downloadedMediaItem)
|
||||
}
|
||||
|
||||
return runCatching {
|
||||
val onlinePlayableMedia = runCatching {
|
||||
getOnlinePlayableMedia(mediaId, downloadedMediaItem)
|
||||
?: getOfflineDownloadedPlayableMedia(mediaId, downloadedMediaItem)
|
||||
}.getOrElse { error ->
|
||||
if (error is CancellationException) throw error
|
||||
networkMonitor.checkConnection()
|
||||
Timber.tag(TAG).w(error, "Unable to load online metadata for downloaded media $mediaId")
|
||||
getOfflineDownloadedPlayableMedia(mediaId, downloadedMediaItem)
|
||||
null
|
||||
}
|
||||
if (onlinePlayableMedia != null) return onlinePlayableMedia
|
||||
|
||||
networkMonitor.checkConnection()
|
||||
return getOfflineDownloadedPlayableMedia(mediaId, downloadedMediaItem)
|
||||
}
|
||||
|
||||
private suspend fun getStreamingPlayableMedia(mediaId: UUID): PlayableMedia? {
|
||||
return getOnlinePlayableMedia(mediaId, downloadedMediaItem = null)
|
||||
if (!networkMonitor.isOnline.first()) return null
|
||||
|
||||
return runCatching {
|
||||
getOnlinePlayableMedia(mediaId, downloadedMediaItem = null)
|
||||
}.getOrElse { error ->
|
||||
if (error is CancellationException) throw error
|
||||
networkMonitor.checkConnection()
|
||||
Timber.tag(TAG).w(error, "Unable to load streaming media $mediaId")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getOnlinePlayableMedia(
|
||||
@@ -86,14 +102,14 @@ class DefaultPlayableMediaRepository @Inject constructor(
|
||||
downloadedMediaItem: MediaItem?,
|
||||
): PlayableMedia? {
|
||||
val baseItem = jellyfinApiClient.getItemInfo(mediaId) ?: return null
|
||||
val playbackDecision = jellyfinPlaybackResolver.getPlaybackDecision(mediaId) ?: return null
|
||||
val playbackSource = jellyfinPlaybackResolver.getPlaybackSource(mediaId) ?: return null
|
||||
|
||||
val mediaItem = if (downloadedMediaItem == null) {
|
||||
getMediaItem(baseItem, playbackDecision)
|
||||
getMediaItem(baseItem, playbackSource)
|
||||
} else {
|
||||
getDownloadedMediaItem(baseItem, playbackDecision, downloadedMediaItem)
|
||||
getDownloadedMediaItem(baseItem, downloadedMediaItem)
|
||||
}
|
||||
val resumePositionMs = calculateResumePosition(baseItem, playbackDecision.mediaSource)
|
||||
val resumePositionMs = calculateResumePosition(baseItem, playbackSource.mediaSource)
|
||||
val preferenceMediaId = when (baseItem.type) {
|
||||
BaseItemKind.EPISODE -> baseItem.seriesId ?: mediaId
|
||||
else -> mediaId
|
||||
@@ -145,7 +161,7 @@ class DefaultPlayableMediaRepository @Inject constructor(
|
||||
artworkUrl = ImageUrlBuilder.finishImageUrl(movie.imageUrlPrefix, ArtworkKind.PRIMARY),
|
||||
playbackReportContext = null,
|
||||
),
|
||||
resumePositionMs = 0L,
|
||||
resumePositionMs = calculateOfflineResumePosition(movie.progress, movie.runtime, movie.watched),
|
||||
preferences = preferences,
|
||||
mediaSegments = emptyList()
|
||||
)
|
||||
@@ -163,7 +179,7 @@ class DefaultPlayableMediaRepository @Inject constructor(
|
||||
artworkUrl = ImageUrlBuilder.finishImageUrl(episode.imageUrlPrefix, ArtworkKind.PRIMARY),
|
||||
playbackReportContext = null,
|
||||
),
|
||||
resumePositionMs = 0L,
|
||||
resumePositionMs = calculateOfflineResumePosition(episode.progress, episode.runtime, episode.watched),
|
||||
preferences = preferences,
|
||||
mediaSegments = emptyList()
|
||||
)
|
||||
@@ -172,7 +188,10 @@ class DefaultPlayableMediaRepository @Inject constructor(
|
||||
return null
|
||||
}
|
||||
|
||||
private suspend fun getMediaItem(baseItem: BaseItemDto, playbackDecision: PlaybackDecision): MediaItem = withContext(Dispatchers.IO) {
|
||||
private suspend fun getMediaItem(
|
||||
baseItem: BaseItemDto,
|
||||
playbackSource: PlaybackSource,
|
||||
): MediaItem = withContext(Dispatchers.IO) {
|
||||
val mediaId = baseItem.id
|
||||
val baseItem = jellyfinApiClient.getItemInfo(mediaId)
|
||||
|
||||
@@ -181,11 +200,11 @@ class DefaultPlayableMediaRepository @Inject constructor(
|
||||
|
||||
val mediaItem = createMediaItem(
|
||||
mediaId = mediaId.toString(),
|
||||
playbackDecision = playbackDecision,
|
||||
title = baseItem?.name ?: playbackDecision.mediaSource.name ?: "Unknown",
|
||||
url = playbackSource.directPlayUrl,
|
||||
title = baseItem?.name ?: "Unknown",
|
||||
subtitle = seasonEpisodeLabel(baseItem),
|
||||
artworkUrl = artworkUrl,
|
||||
playbackReportContext = playbackDecision.reportContext,
|
||||
playbackTag = playbackSource.toPlaybackMediaItemTag(),
|
||||
)
|
||||
|
||||
return@withContext mediaItem
|
||||
@@ -193,7 +212,6 @@ class DefaultPlayableMediaRepository @Inject constructor(
|
||||
|
||||
private suspend fun getDownloadedMediaItem(
|
||||
baseItem: BaseItemDto,
|
||||
playbackDecision: PlaybackDecision,
|
||||
downloadedMediaItem: MediaItem,
|
||||
): MediaItem = withContext(Dispatchers.IO) {
|
||||
val mediaId = baseItem.id
|
||||
@@ -203,10 +221,11 @@ class DefaultPlayableMediaRepository @Inject constructor(
|
||||
|
||||
return@withContext downloadedMediaItem.withMetadata(
|
||||
mediaId = mediaId.toString(),
|
||||
title = baseItem?.name ?: playbackDecision.mediaSource.name ?: "Unknown",
|
||||
title = baseItem?.name ?: "Unknown",
|
||||
subtitle = seasonEpisodeLabel(baseItem),
|
||||
artworkUrl = artworkUrl,
|
||||
playbackReportContext = playbackDecision.reportContext,
|
||||
// TODO
|
||||
playbackReportContext = null,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -222,7 +241,8 @@ class DefaultPlayableMediaRepository @Inject constructor(
|
||||
existingIds: Set<UUID>,
|
||||
count: Int,
|
||||
): List<PlayableMedia> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
// First try the online (Jellyfin API) path
|
||||
val onlineResult = runCatching {
|
||||
val episodes = jellyfinApiClient.getNextEpisodes(episodeId = episodeId, count = count)
|
||||
episodes.mapNotNull { episode ->
|
||||
val id = episode.id ?: return@mapNotNull null
|
||||
@@ -232,19 +252,37 @@ class DefaultPlayableMediaRepository @Inject constructor(
|
||||
getPlayableMedia(id)
|
||||
}
|
||||
}.getOrElse { error ->
|
||||
Timber.tag(TAG).w(error, "Unable to load next-up items for $episodeId")
|
||||
emptyList()
|
||||
Timber.tag(TAG).w(error, "Unable to load next-up items from API for $episodeId")
|
||||
null // Signal that we should try the offline fallback
|
||||
}
|
||||
if (onlineResult != null) return@withContext onlineResult
|
||||
|
||||
// Offline fallback: find next episodes from the local database
|
||||
Timber.tag(TAG).d("Falling back to offline next-up resolution for $episodeId")
|
||||
val currentEpisode = offlineMediaRepository.getEpisode(episodeId).first() ?: return@withContext emptyList()
|
||||
val seriesEpisodes = offlineMediaManager.getEpisodesBySeries(currentEpisode.seriesId)
|
||||
.sortedWith(compareBy({ it.seasonIndex }, { it.index }))
|
||||
|
||||
val currentIndex = seriesEpisodes.indexOfFirst { it.id == episodeId }
|
||||
if (currentIndex == -1) return@withContext emptyList()
|
||||
|
||||
seriesEpisodes
|
||||
.drop(currentIndex + 1)
|
||||
.take(count)
|
||||
.filter { it.id !in existingIds }
|
||||
.mapNotNull { nextEpisode ->
|
||||
getPlayableMedia(nextEpisode.id)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
private fun createMediaItem(
|
||||
mediaId: String,
|
||||
playbackDecision: PlaybackDecision,
|
||||
url: String,
|
||||
title: String,
|
||||
subtitle: String?,
|
||||
artworkUrl: String,
|
||||
playbackReportContext: PlaybackReportContext?,
|
||||
playbackTag: Any?,
|
||||
): MediaItem {
|
||||
val metadata = MediaMetadata.Builder()
|
||||
.setTitle(title)
|
||||
@@ -252,20 +290,20 @@ class DefaultPlayableMediaRepository @Inject constructor(
|
||||
.setArtworkUri(artworkUrl.toUri())
|
||||
.build()
|
||||
val builder = MediaItem.Builder()
|
||||
.setUri(playbackDecision.url.toUri())
|
||||
.setUri(url.toUri())
|
||||
.setMediaId(mediaId)
|
||||
.setMediaMetadata(metadata)
|
||||
.setTag(playbackReportContext)
|
||||
|
||||
playbackCustomCacheKey(
|
||||
mediaId = mediaId,
|
||||
playbackUrl = playbackDecision.url,
|
||||
playMethod = playbackDecision.reportContext.playMethod,
|
||||
)?.let(builder::setCustomCacheKey)
|
||||
|
||||
.setTag(playbackTag)
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
private fun PlaybackSource.toPlaybackMediaItemTag(): PlaybackMediaItemTag {
|
||||
return PlaybackMediaItemTag(
|
||||
playbackReportContext = playbackReportContext,
|
||||
transcodingFallbackUrl = transcodingUrl,
|
||||
)
|
||||
}
|
||||
|
||||
private fun calculateResumePosition(
|
||||
baseItem: BaseItemDto?,
|
||||
mediaSource: MediaSourceInfo,
|
||||
@@ -310,6 +348,39 @@ class DefaultPlayableMediaRepository @Inject constructor(
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the resume position for offline media from the stored progress percentage
|
||||
* and runtime string. Applies the same 5%-95% threshold logic as [calculateResumePosition].
|
||||
*/
|
||||
private fun calculateOfflineResumePosition(
|
||||
progress: Double?,
|
||||
runtime: String,
|
||||
watched: Boolean,
|
||||
): Long {
|
||||
if (watched) return 0L
|
||||
val progressPercent = progress ?: return 0L
|
||||
if (progressPercent.isNaN() || progressPercent <= 0.0) return 0L
|
||||
|
||||
val runtimeMs = parseRuntimeToMs(runtime) ?: return 0L
|
||||
if (runtimeMs <= 0L) return 0L
|
||||
|
||||
val positionMs = ((progressPercent / 100.0) * runtimeMs).toLong()
|
||||
return if (progressPercent in 5.0..95.0) positionMs else 0L
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a runtime string in the format "2h 1m" or "25m" (produced by [formatRuntime])
|
||||
* into milliseconds. Returns null for unparseable values like "—".
|
||||
*/
|
||||
private fun parseRuntimeToMs(runtime: String): Long? {
|
||||
if (runtime.isBlank() || runtime == "—") return null
|
||||
val regex = Regex("""(?:(\d+)h\s*)?(\d+)m""")
|
||||
val match = regex.matchEntire(runtime.trim()) ?: return null
|
||||
val hours = match.groupValues[1].toLongOrNull() ?: 0L
|
||||
val minutes = match.groupValues[2].toLongOrNull() ?: 0L
|
||||
return TimeUnit.MINUTES.toMillis(hours * 60 + minutes)
|
||||
}
|
||||
|
||||
private fun MediaSegmentDto.toMediaSegment(): MediaSegment {
|
||||
val segmentType = when (type) {
|
||||
INTRO -> SegmentType.INTRO
|
||||
|
||||
@@ -4,13 +4,16 @@ import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.IntoSet
|
||||
import hu.bbara.purefin.core.data.AuthenticationRepository
|
||||
import hu.bbara.purefin.core.data.DownloadMediaSourceResolver
|
||||
import hu.bbara.purefin.core.data.NetworkMonitor
|
||||
import hu.bbara.purefin.core.data.PlayableMediaRepository
|
||||
import hu.bbara.purefin.core.data.PlaybackProgressReporter
|
||||
import hu.bbara.purefin.core.data.SessionBootstrapper
|
||||
import hu.bbara.purefin.core.feature.browse.home.refresh.HomeRefreshSideEffect
|
||||
import hu.bbara.purefin.data.jellyfin.download.JellyfinDownloadMediaSourceResolver
|
||||
import hu.bbara.purefin.data.jellyfin.playback.SyncPlaybackPositionsHomeRefreshSideEffect
|
||||
import hu.bbara.purefin.data.jellyfin.session.JellyfinAuthenticationRepository
|
||||
import hu.bbara.purefin.data.jellyfin.session.JellyfinSessionBootstrapper
|
||||
import javax.inject.Singleton
|
||||
@@ -37,6 +40,12 @@ abstract class JellyfinBindingsModule {
|
||||
@Binds
|
||||
abstract fun bindPlaybackProgressReporter(impl: JellyfinPlaybackProgressReporter): PlaybackProgressReporter
|
||||
|
||||
@Binds
|
||||
@IntoSet
|
||||
abstract fun bindSyncPlaybackPositionsHomeRefreshSideEffect(
|
||||
impl: SyncPlaybackPositionsHomeRefreshSideEffect
|
||||
): HomeRefreshSideEffect
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindNetworkMonitor(impl: ConnectivityNetworkMonitor): NetworkMonitor
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
package hu.bbara.purefin.data.jellyfin
|
||||
|
||||
import hu.bbara.purefin.core.data.LocalMediaRepository
|
||||
import hu.bbara.purefin.core.data.MediaMetadataUpdater
|
||||
import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient
|
||||
import hu.bbara.purefin.core.jellyfin.JellyfinMediaMetadataUpdater
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class JellyfinMediaMetadataUpdaterImpl @Inject constructor(
|
||||
private val jellyfinApiClient: JellyfinApiClient
|
||||
) : JellyfinMediaMetadataUpdater {
|
||||
private val jellyfinApiClient: JellyfinApiClient,
|
||||
private val localMediaRepository: LocalMediaRepository,
|
||||
) : MediaMetadataUpdater {
|
||||
|
||||
override suspend fun markAsWatched(mediaId: UUID, watched: Boolean) {
|
||||
if (watched) {
|
||||
@@ -15,5 +19,22 @@ class JellyfinMediaMetadataUpdaterImpl @Inject constructor(
|
||||
} else {
|
||||
jellyfinApiClient.markAsUnwatched(mediaId)
|
||||
}
|
||||
localMediaRepository.markAsWatched(mediaId, watched)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long) {
|
||||
localMediaRepository.updateWatchProgress(mediaId, positionMs, durationMs)
|
||||
}
|
||||
|
||||
override suspend fun updateWatchProgressPercent(mediaId: UUID, progressPercent: Double) {
|
||||
localMediaRepository.updateWatchProgressPercent(mediaId, progressPercent)
|
||||
}
|
||||
|
||||
override suspend fun updatePlaybackPosition(
|
||||
mediaId: UUID,
|
||||
playbackPositionTicks: Long,
|
||||
runtimeTicks: Long,
|
||||
) {
|
||||
jellyfinApiClient.updatePlaybackPosition(mediaId, playbackPositionTicks, runtimeTicks)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,12 @@ import android.content.Context
|
||||
import android.os.SystemClock
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import hu.bbara.purefin.core.data.JellyfinServerCandidate
|
||||
import hu.bbara.purefin.core.data.NetworkMonitor
|
||||
import hu.bbara.purefin.core.data.PlaybackMethod
|
||||
import hu.bbara.purefin.core.data.PlaybackReportContext
|
||||
import hu.bbara.purefin.core.data.QuickConnectSession
|
||||
import hu.bbara.purefin.core.data.UserSessionRepository
|
||||
import hu.bbara.purefin.core.jellyfin.JellyfinSdkClient
|
||||
import hu.bbara.purefin.data.jellyfin.etag.NotModifiedException
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -27,12 +28,13 @@ import org.jellyfin.sdk.api.client.extensions.mediaSegmentsApi
|
||||
import org.jellyfin.sdk.api.client.extensions.playStateApi
|
||||
import org.jellyfin.sdk.api.client.extensions.quickConnectApi
|
||||
import org.jellyfin.sdk.api.client.extensions.suggestionsApi
|
||||
import org.jellyfin.sdk.api.client.extensions.systemApi
|
||||
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
||||
import org.jellyfin.sdk.api.client.extensions.videosApi
|
||||
import org.jellyfin.sdk.api.operations.SystemApi
|
||||
import org.jellyfin.sdk.api.okhttp.OkHttpFactory
|
||||
import org.jellyfin.sdk.createJellyfin
|
||||
import org.jellyfin.sdk.discovery.RecommendedServerInfo
|
||||
import org.jellyfin.sdk.discovery.RecommendedServerInfoScore
|
||||
@@ -45,7 +47,6 @@ import org.jellyfin.sdk.model.api.CollectionType
|
||||
import org.jellyfin.sdk.model.api.DeviceProfile
|
||||
import org.jellyfin.sdk.model.api.ItemFields
|
||||
import org.jellyfin.sdk.model.api.MediaSegmentDto
|
||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||
import org.jellyfin.sdk.model.api.MediaType
|
||||
import org.jellyfin.sdk.model.api.PlayMethod
|
||||
import org.jellyfin.sdk.model.api.PlaybackInfoDto
|
||||
@@ -55,6 +56,7 @@ import org.jellyfin.sdk.model.api.PlaybackProgressInfo
|
||||
import org.jellyfin.sdk.model.api.PlaybackStartInfo
|
||||
import org.jellyfin.sdk.model.api.PlaybackStopInfo
|
||||
import org.jellyfin.sdk.model.api.RepeatMode
|
||||
import org.jellyfin.sdk.model.api.UpdateUserItemDataDto
|
||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
|
||||
@@ -65,13 +67,14 @@ import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class JellyfinApiClient @Inject constructor(
|
||||
@param:ApplicationContext private val applicationContext: Context,
|
||||
@ApplicationContext private val applicationContext: Context,
|
||||
private val userSessionRepository: UserSessionRepository,
|
||||
private val networkMonitor: NetworkMonitor,
|
||||
@JellyfinSdkClient private val okHttpFactory: OkHttpFactory,
|
||||
) {
|
||||
private val jellyfin = createJellyfin {
|
||||
context = applicationContext
|
||||
clientInfo = ClientInfo(name = "Purefin", version = "0.0.1")
|
||||
apiClientFactory = okHttpFactory
|
||||
}
|
||||
|
||||
private val api = jellyfin.createApi()
|
||||
@@ -216,105 +219,271 @@ class JellyfinApiClient @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getLibraries(): List<BaseItemDto> = withContext(Dispatchers.IO) {
|
||||
suspend fun getLibraries(): List<BaseItemDto>? = withContext(Dispatchers.IO) {
|
||||
logRequest("getLibraries") {
|
||||
if (!ensureConfigured()) {
|
||||
return@logRequest emptyList()
|
||||
return@logRequest emptyList<BaseItemDto>()
|
||||
}
|
||||
try {
|
||||
val response = api.userViewsApi.getUserViews(
|
||||
userId = getUserId(),
|
||||
presetViews = listOf(CollectionType.MOVIES, CollectionType.TVSHOWS),
|
||||
includeHidden = false,
|
||||
)
|
||||
response.content.items
|
||||
} catch (e: NotModifiedException) {
|
||||
null
|
||||
}
|
||||
val response = api.userViewsApi.getUserViews(
|
||||
userId = getUserId(),
|
||||
presetViews = listOf(CollectionType.MOVIES, CollectionType.TVSHOWS),
|
||||
includeHidden = false,
|
||||
)
|
||||
response.content.items
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getLibraryContent(libraryId: UUID): List<BaseItemDto> = withContext(Dispatchers.IO) {
|
||||
suspend fun probeServer(): Boolean = withContext(Dispatchers.IO) {
|
||||
logRequest("probeServer") {
|
||||
if (!ensureConfigured()) {
|
||||
return@logRequest false
|
||||
}
|
||||
api.systemApi.getPingSystem()
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getLibraryContent(libraryId: UUID): List<BaseItemDto>? = withContext(Dispatchers.IO) {
|
||||
logRequest("getLibraryContent") {
|
||||
if (!ensureConfigured()) {
|
||||
return@logRequest emptyList()
|
||||
return@logRequest emptyList<BaseItemDto>()
|
||||
}
|
||||
try {
|
||||
val getItemsRequest = GetItemsRequest(
|
||||
userId = getUserId(),
|
||||
enableImages = true,
|
||||
parentId = libraryId,
|
||||
fields = defaultItemFields + ItemFields.OVERVIEW,
|
||||
enableUserData = true,
|
||||
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
|
||||
recursive = true,
|
||||
)
|
||||
val response = api.itemsApi.getItems(getItemsRequest)
|
||||
response.content.items
|
||||
} catch (e: NotModifiedException) {
|
||||
null
|
||||
}
|
||||
val getItemsRequest = GetItemsRequest(
|
||||
userId = getUserId(),
|
||||
enableImages = true,
|
||||
parentId = libraryId,
|
||||
fields = defaultItemFields + ItemFields.OVERVIEW,
|
||||
enableUserData = true,
|
||||
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
|
||||
recursive = true,
|
||||
)
|
||||
val response = api.itemsApi.getItems(getItemsRequest)
|
||||
response.content.items
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getSuggestions(): List<BaseItemDto> = withContext(Dispatchers.IO) {
|
||||
/**
|
||||
* Returns the total number of items in [libraryId] without enumerating
|
||||
* them. Uses `getItems` with `limit=0, enableTotalRecordCount=true`,
|
||||
* which the Jellyfin server short-circuits to a single integer in the
|
||||
* response. The home page uses this to populate `Library.size` for the
|
||||
* library cards on the dashboard without paying for the full library
|
||||
* content.
|
||||
*
|
||||
* ETag is honored via the [ETagInterceptor]: `null` means the cached
|
||||
* count is still current.
|
||||
*/
|
||||
suspend fun getLibraryItemCount(libraryId: UUID): Int? = withContext(Dispatchers.IO) {
|
||||
logRequest("getLibraryItemCount") {
|
||||
if (!ensureConfigured()) {
|
||||
return@logRequest 0
|
||||
}
|
||||
try {
|
||||
val request = GetItemsRequest(
|
||||
userId = getUserId(),
|
||||
parentId = libraryId,
|
||||
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
|
||||
recursive = true,
|
||||
limit = 0,
|
||||
enableTotalRecordCount = true,
|
||||
)
|
||||
val response = api.itemsApi.getItems(request)
|
||||
response.content.totalRecordCount
|
||||
} catch (e: NotModifiedException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getSuggestions(): List<BaseItemDto>? = withContext(Dispatchers.IO) {
|
||||
logRequest("getSuggestions") {
|
||||
if (!ensureConfigured()) {
|
||||
return@logRequest emptyList()
|
||||
return@logRequest emptyList<BaseItemDto>()
|
||||
}
|
||||
val userId = getUserId() ?: return@logRequest emptyList<BaseItemDto>()
|
||||
try {
|
||||
val response = api.suggestionsApi.getSuggestions(
|
||||
userId = userId,
|
||||
mediaType = listOf(MediaType.VIDEO),
|
||||
type = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
|
||||
limit = 8,
|
||||
enableTotalRecordCount = true,
|
||||
)
|
||||
response.content.items
|
||||
} catch (e: NotModifiedException) {
|
||||
null
|
||||
}
|
||||
val userId = getUserId() ?: return@logRequest emptyList()
|
||||
val response = api.suggestionsApi.getSuggestions(
|
||||
userId = userId,
|
||||
mediaType = listOf(MediaType.VIDEO),
|
||||
type = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
|
||||
limit = 8,
|
||||
enableTotalRecordCount = true,
|
||||
)
|
||||
response.content.items
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getContinueWatching(): List<BaseItemDto> = withContext(Dispatchers.IO) {
|
||||
suspend fun getContinueWatching(): List<BaseItemDto>? = withContext(Dispatchers.IO) {
|
||||
logRequest("getContinueWatching") {
|
||||
if (!ensureConfigured()) {
|
||||
return@logRequest emptyList()
|
||||
return@logRequest emptyList<BaseItemDto>()
|
||||
}
|
||||
val userId = getUserId() ?: return@logRequest emptyList<BaseItemDto>()
|
||||
try {
|
||||
val getResumeItemsRequest = GetResumeItemsRequest(
|
||||
userId = userId,
|
||||
fields = defaultItemFields + ItemFields.OVERVIEW,
|
||||
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE),
|
||||
enableUserData = true,
|
||||
startIndex = 0,
|
||||
)
|
||||
val response: Response<BaseItemDtoQueryResult> = api.itemsApi.getResumeItems(getResumeItemsRequest)
|
||||
response.content.items
|
||||
} catch (e: NotModifiedException) {
|
||||
null
|
||||
}
|
||||
val userId = getUserId() ?: return@logRequest emptyList()
|
||||
val getResumeItemsRequest = GetResumeItemsRequest(
|
||||
userId = userId,
|
||||
fields = defaultItemFields + ItemFields.OVERVIEW,
|
||||
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE),
|
||||
enableUserData = true,
|
||||
startIndex = 0,
|
||||
)
|
||||
val response: Response<BaseItemDtoQueryResult> = api.itemsApi.getResumeItems(getResumeItemsRequest)
|
||||
response.content.items
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getNextUpEpisodes(): List<BaseItemDto> = withContext(Dispatchers.IO) {
|
||||
suspend fun getNextUpEpisodes(): List<BaseItemDto>? = withContext(Dispatchers.IO) {
|
||||
logRequest("getNextUpEpisodes") {
|
||||
if (!ensureConfigured()) {
|
||||
throw IllegalStateException("Not configured")
|
||||
return@logRequest emptyList<BaseItemDto>()
|
||||
}
|
||||
try {
|
||||
val getNextUpRequest = GetNextUpRequest(
|
||||
userId = getUserId(),
|
||||
fields = defaultItemFields + ItemFields.OVERVIEW,
|
||||
enableResumable = false,
|
||||
)
|
||||
val result = api.tvShowsApi.getNextUp(getNextUpRequest)
|
||||
result.content.items
|
||||
} catch (e: NotModifiedException) {
|
||||
null
|
||||
}
|
||||
val getNextUpRequest = GetNextUpRequest(
|
||||
userId = getUserId(),
|
||||
fields = defaultItemFields + ItemFields.OVERVIEW,
|
||||
enableResumable = false,
|
||||
)
|
||||
val result = api.tvShowsApi.getNextUp(getNextUpRequest)
|
||||
result.content.items
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getLatestFromLibrary(libraryId: UUID): List<BaseItemDto> = withContext(Dispatchers.IO) {
|
||||
suspend fun getLatestFromLibrary(libraryId: UUID): List<BaseItemDto>? = withContext(Dispatchers.IO) {
|
||||
logRequest("getLatestFromLibrary") {
|
||||
if (!ensureConfigured()) {
|
||||
return@logRequest emptyList()
|
||||
return@logRequest emptyList<BaseItemDto>()
|
||||
}
|
||||
try {
|
||||
val response = api.userLibraryApi.getLatestMedia(
|
||||
userId = getUserId(),
|
||||
parentId = libraryId,
|
||||
fields = defaultItemFields + ItemFields.OVERVIEW,
|
||||
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE, BaseItemKind.SEASON),
|
||||
limit = 15,
|
||||
)
|
||||
response.content
|
||||
} catch (e: NotModifiedException) {
|
||||
null
|
||||
}
|
||||
val response = api.userLibraryApi.getLatestMedia(
|
||||
userId = getUserId(),
|
||||
parentId = libraryId,
|
||||
fields = defaultItemFields + ItemFields.OVERVIEW,
|
||||
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE, BaseItemKind.SEASON),
|
||||
limit = 15,
|
||||
)
|
||||
response.content
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Head-only fetch for the suggestions row. Returns the first [limit]
|
||||
* items so the caller can diff against the cached head and decide
|
||||
* whether the full request is necessary. ETag is honored via the
|
||||
* [ETagInterceptor]; `null` means the cached head is still current.
|
||||
*/
|
||||
suspend fun getSuggestionsHead(limit: Int = HEAD_LIMIT): List<BaseItemDto>? = withContext(Dispatchers.IO) {
|
||||
logRequest("getSuggestionsHead") {
|
||||
if (!ensureConfigured()) {
|
||||
return@logRequest emptyList<BaseItemDto>()
|
||||
}
|
||||
try {
|
||||
val response = api.suggestionsApi.getSuggestions(
|
||||
userId = getUserId(),
|
||||
mediaType = listOf(MediaType.VIDEO),
|
||||
type = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
|
||||
limit = limit,
|
||||
enableTotalRecordCount = false,
|
||||
)
|
||||
response.content.items
|
||||
} catch (e: NotModifiedException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Head-only fetch for the continue-watching row. See [getSuggestionsHead].
|
||||
*/
|
||||
suspend fun getContinueWatchingHead(limit: Int = HEAD_LIMIT): List<BaseItemDto>? = withContext(Dispatchers.IO) {
|
||||
logRequest("getContinueWatchingHead") {
|
||||
if (!ensureConfigured()) {
|
||||
return@logRequest emptyList<BaseItemDto>()
|
||||
}
|
||||
val userId = getUserId() ?: return@logRequest emptyList<BaseItemDto>()
|
||||
try {
|
||||
val getResumeItemsRequest = GetResumeItemsRequest(
|
||||
userId = userId,
|
||||
fields = defaultItemFields,
|
||||
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE),
|
||||
enableUserData = true,
|
||||
startIndex = 0,
|
||||
limit = limit,
|
||||
)
|
||||
val response: Response<BaseItemDtoQueryResult> = api.itemsApi.getResumeItems(getResumeItemsRequest)
|
||||
response.content.items
|
||||
} catch (e: NotModifiedException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Head-only fetch for the next-up row. See [getSuggestionsHead].
|
||||
*/
|
||||
suspend fun getNextUpHead(limit: Int = HEAD_LIMIT): List<BaseItemDto>? = withContext(Dispatchers.IO) {
|
||||
logRequest("getNextUpHead") {
|
||||
if (!ensureConfigured()) {
|
||||
return@logRequest emptyList<BaseItemDto>()
|
||||
}
|
||||
try {
|
||||
val getNextUpRequest = GetNextUpRequest(
|
||||
userId = getUserId(),
|
||||
fields = defaultItemFields,
|
||||
enableResumable = false,
|
||||
limit = limit,
|
||||
)
|
||||
val result = api.tvShowsApi.getNextUp(getNextUpRequest)
|
||||
result.content.items
|
||||
} catch (e: NotModifiedException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Head-only fetch for the latest row of a library. See [getSuggestionsHead].
|
||||
*/
|
||||
suspend fun getLatestFromLibraryHead(libraryId: UUID, limit: Int = HEAD_LIMIT): List<BaseItemDto>? =
|
||||
withContext(Dispatchers.IO) {
|
||||
logRequest("getLatestFromLibraryHead") {
|
||||
if (!ensureConfigured()) {
|
||||
return@logRequest emptyList<BaseItemDto>()
|
||||
}
|
||||
try {
|
||||
val response = api.userLibraryApi.getLatestMedia(
|
||||
userId = getUserId(),
|
||||
parentId = libraryId,
|
||||
fields = defaultItemFields,
|
||||
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE, BaseItemKind.SEASON),
|
||||
limit = limit,
|
||||
)
|
||||
response.content
|
||||
} catch (e: NotModifiedException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getItemInfo(mediaId: UUID): BaseItemDto? = withContext(Dispatchers.IO) {
|
||||
logRequest("getItemInfo") {
|
||||
if (!ensureConfigured()) {
|
||||
@@ -388,6 +557,30 @@ class JellyfinApiClient @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updatePlaybackPosition(
|
||||
mediaId: UUID,
|
||||
playbackPositionTicks: Long,
|
||||
runtimeTicks: Long,
|
||||
) = withContext(Dispatchers.IO) {
|
||||
if (runtimeTicks <= 0L) return@withContext
|
||||
val normalizedPlaybackPositionTicks = playbackPositionTicks.coerceIn(0L, runtimeTicks)
|
||||
val isPastThreshold = normalizedPlaybackPositionTicks.toDouble() / runtimeTicks.toDouble() >= 0.8
|
||||
logRequest("updatePlaybackPosition") {
|
||||
if (!ensureConfigured()) {
|
||||
return@logRequest
|
||||
}
|
||||
val result = api.itemsApi.updateItemUserData(
|
||||
itemId = mediaId,
|
||||
userId = getUserId(),
|
||||
data = UpdateUserItemDataDto(
|
||||
playbackPositionTicks = if (isPastThreshold) runtimeTicks else normalizedPlaybackPositionTicks,
|
||||
played = isPastThreshold,
|
||||
)
|
||||
)
|
||||
result.content
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun markAsWatched(mediaId: UUID) = withContext(Dispatchers.IO) {
|
||||
logRequest("markAsWatched") {
|
||||
if (!ensureConfigured()) {
|
||||
@@ -412,23 +605,6 @@ class JellyfinApiClient @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getMediaSources(mediaId: UUID): List<MediaSourceInfo> = withContext(Dispatchers.IO) {
|
||||
logRequest("getMediaSources") {
|
||||
if (!ensureConfigured()) {
|
||||
return@logRequest emptyList()
|
||||
}
|
||||
val result = api.mediaInfoApi.getPostedPlaybackInfo(
|
||||
mediaId,
|
||||
PlaybackInfoDto(
|
||||
userId = getUserId(),
|
||||
deviceProfile = null,
|
||||
maxStreamingBitrate = 100_000_000,
|
||||
),
|
||||
)
|
||||
result.content.mediaSources
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getMediaSegments(mediaId: UUID) : List<MediaSegmentDto> = withContext(Dispatchers.IO) {
|
||||
logRequest("getMediaSegments") {
|
||||
if (!ensureConfigured()) {
|
||||
@@ -467,21 +643,11 @@ class JellyfinApiClient @Inject constructor(
|
||||
}
|
||||
|
||||
fun getVideoStreamUrl(
|
||||
itemId: UUID,
|
||||
mediaSourceId: String?,
|
||||
container: String? = null,
|
||||
tag: String? = null,
|
||||
playSessionId: String? = null,
|
||||
liveStreamId: String? = null,
|
||||
itemId: UUID
|
||||
): String = try {
|
||||
api.videosApi.getVideoStreamUrl(
|
||||
itemId = itemId,
|
||||
container = container,
|
||||
mediaSourceId = mediaSourceId,
|
||||
static = true,
|
||||
tag = tag,
|
||||
playSessionId = playSessionId,
|
||||
liveStreamId = liveStreamId,
|
||||
)
|
||||
} catch (error: Exception) {
|
||||
Timber.tag(TAG).e(error, "getVideoStreamUrl")
|
||||
@@ -497,15 +663,6 @@ class JellyfinApiClient @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getPublicSystemInfoVersion(): String? = withContext(Dispatchers.IO) {
|
||||
logRequest("getPublicSystemInfoVersion") {
|
||||
if (!ensureConfigured()) {
|
||||
return@logRequest null
|
||||
}
|
||||
SystemApi(api).getPublicSystemInfo().content.version
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun reportPlaybackStart(
|
||||
itemId: UUID,
|
||||
positionTicks: Long = 0L,
|
||||
@@ -523,7 +680,6 @@ class JellyfinApiClient @Inject constructor(
|
||||
mediaSourceId = reportContext.mediaSourceId,
|
||||
audioStreamIndex = reportContext.audioStreamIndex,
|
||||
subtitleStreamIndex = reportContext.subtitleStreamIndex,
|
||||
liveStreamId = reportContext.liveStreamId,
|
||||
playSessionId = reportContext.playSessionId,
|
||||
playMethod = reportContext.playMethod.toJellyfinPlayMethod(),
|
||||
repeatMode = RepeatMode.REPEAT_NONE,
|
||||
@@ -551,7 +707,6 @@ class JellyfinApiClient @Inject constructor(
|
||||
mediaSourceId = reportContext.mediaSourceId,
|
||||
audioStreamIndex = reportContext.audioStreamIndex,
|
||||
subtitleStreamIndex = reportContext.subtitleStreamIndex,
|
||||
liveStreamId = reportContext.liveStreamId,
|
||||
playSessionId = reportContext.playSessionId,
|
||||
playMethod = reportContext.playMethod.toJellyfinPlayMethod(),
|
||||
repeatMode = RepeatMode.REPEAT_NONE,
|
||||
@@ -573,7 +728,6 @@ class JellyfinApiClient @Inject constructor(
|
||||
itemId = itemId,
|
||||
positionTicks = positionTicks,
|
||||
mediaSourceId = reportContext.mediaSourceId,
|
||||
liveStreamId = reportContext.liveStreamId,
|
||||
playSessionId = reportContext.playSessionId,
|
||||
failed = false,
|
||||
),
|
||||
@@ -585,14 +739,37 @@ class JellyfinApiClient @Inject constructor(
|
||||
val startedAt = SystemClock.elapsedRealtime()
|
||||
return try {
|
||||
val result = block()
|
||||
networkMonitor.reportRequestSucceeded()
|
||||
val elapsedMs = SystemClock.elapsedRealtime() - startedAt
|
||||
Timber.tag(TAG).d("$functionName took ${elapsedMs}ms")
|
||||
val sizeLog = when (result) {
|
||||
is List<*> -> {
|
||||
val items = result.size
|
||||
if (items == 0) {
|
||||
" (empty)"
|
||||
} else {
|
||||
val bytes = result.toString().toByteArray().size
|
||||
val sizeStr = when {
|
||||
bytes < 1024 -> "$bytes B"
|
||||
bytes < 1024 * 1024 -> "${bytes / 1024} KB"
|
||||
else -> "%.1f MB".format(bytes.toFloat() / (1024 * 1024))
|
||||
}
|
||||
" ($items items, $sizeStr)"
|
||||
}
|
||||
}
|
||||
is Unit -> ""
|
||||
else -> {
|
||||
val bytes = result.toString().toByteArray().size
|
||||
when {
|
||||
bytes < 1024 -> " ($bytes B)"
|
||||
bytes < 1024 * 1024 -> " (${bytes / 1024} KB)"
|
||||
else -> " (%.1f MB)".format(bytes.toFloat() / (1024 * 1024))
|
||||
}
|
||||
}
|
||||
}
|
||||
Timber.tag(TAG).d("$functionName took ${elapsedMs}ms$sizeLog")
|
||||
result
|
||||
} catch (error: CancellationException) {
|
||||
throw error
|
||||
} catch (error: Exception) {
|
||||
networkMonitor.reportRequestFailed(error)
|
||||
val elapsedMs = SystemClock.elapsedRealtime() - startedAt
|
||||
Timber.tag(TAG).e(error, "$functionName failed after ${elapsedMs}ms")
|
||||
throw error
|
||||
@@ -619,5 +796,9 @@ class JellyfinApiClient @Inject constructor(
|
||||
|
||||
companion object {
|
||||
private const val TAG = "JellyfinApiClient"
|
||||
// How many items to fetch on the head-only path. Two is enough to
|
||||
// detect the common case of "first card unchanged" without making
|
||||
// the head request meaningfully heavier than the minimum.
|
||||
const val HEAD_LIMIT = 2
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package hu.bbara.purefin.data.jellyfin.download
|
||||
import hu.bbara.purefin.core.data.DownloadMediaSourceResolver
|
||||
import hu.bbara.purefin.core.data.EpisodeDownloadSource
|
||||
import hu.bbara.purefin.core.data.MovieDownloadSource
|
||||
import hu.bbara.purefin.core.data.PlaybackMethod
|
||||
import hu.bbara.purefin.core.data.UserSessionRepository
|
||||
import hu.bbara.purefin.data.converter.toEpisode
|
||||
import hu.bbara.purefin.data.converter.toMovie
|
||||
@@ -11,7 +10,7 @@ import hu.bbara.purefin.data.converter.toSeason
|
||||
import hu.bbara.purefin.data.converter.toSeries
|
||||
import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient
|
||||
import hu.bbara.purefin.data.jellyfin.playback.JellyfinPlaybackResolver
|
||||
import hu.bbara.purefin.data.jellyfin.playback.PlaybackDecision
|
||||
import hu.bbara.purefin.data.jellyfin.playback.PlaybackSource
|
||||
import hu.bbara.purefin.data.jellyfin.playback.playbackCustomCacheKey
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
@@ -32,13 +31,13 @@ class JellyfinDownloadMediaSourceResolver @Inject constructor(
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
val playbackDecision = jellyfinPlaybackResolver.getPlaybackDecision(movieId) ?: return@withContext null
|
||||
val playbackSource = jellyfinPlaybackResolver.getPlaybackSource(movieId) ?: return@withContext null
|
||||
val itemInfo = jellyfinApiClient.getItemInfo(movieId) ?: return@withContext null
|
||||
|
||||
MovieDownloadSource(
|
||||
movie = itemInfo.toMovie(serverUrl),
|
||||
playbackUrl = playbackDecision.url,
|
||||
customCacheKey = playbackDecision.downloadCustomCacheKey(movieId),
|
||||
playbackUrl = playbackSource.directPlayUrl,
|
||||
customCacheKey = playbackSource.downloadCustomCacheKey(movieId),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -48,7 +47,7 @@ class JellyfinDownloadMediaSourceResolver @Inject constructor(
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
val playbackDecision = jellyfinPlaybackResolver.getPlaybackDecision(episodeId) ?: return@withContext null
|
||||
val playbackSource = jellyfinPlaybackResolver.getPlaybackSource(episodeId) ?: return@withContext null
|
||||
val episodeDto = jellyfinApiClient.getItemInfo(episodeId) ?: return@withContext null
|
||||
val episode = episodeDto.toEpisode(serverUrl)
|
||||
val series = jellyfinApiClient.getItemInfo(episode.seriesId)?.toSeries(serverUrl) ?: return@withContext null
|
||||
@@ -58,8 +57,8 @@ class JellyfinDownloadMediaSourceResolver @Inject constructor(
|
||||
episode = episode,
|
||||
series = series,
|
||||
season = season,
|
||||
playbackUrl = playbackDecision.url,
|
||||
customCacheKey = playbackDecision.downloadCustomCacheKey(episodeId),
|
||||
playbackUrl = playbackSource.directPlayUrl,
|
||||
customCacheKey = playbackSource.downloadCustomCacheKey(episodeId),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -91,14 +90,11 @@ class JellyfinDownloadMediaSourceResolver @Inject constructor(
|
||||
.map { it.id }
|
||||
}
|
||||
|
||||
private fun PlaybackDecision.downloadCustomCacheKey(mediaId: UUID): String? {
|
||||
if (reportContext.playMethod != PlaybackMethod.DIRECT_PLAY) {
|
||||
return null
|
||||
}
|
||||
private fun PlaybackSource.downloadCustomCacheKey(mediaId: UUID): String? {
|
||||
return playbackCustomCacheKey(
|
||||
mediaId = mediaId.toString(),
|
||||
playbackUrl = url,
|
||||
playMethod = reportContext.playMethod,
|
||||
playbackUrl = directPlayUrl,
|
||||
playMethod = playbackReportContext.playMethod,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package hu.bbara.purefin.data.jellyfin.etag
|
||||
|
||||
import okhttp3.HttpUrl
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* In-memory store of `ETag` response headers, keyed by full request URL. The
|
||||
* [ETagInterceptor] reads from this store to add `If-None-Match` to outgoing
|
||||
* requests and writes to it on every successful response.
|
||||
*
|
||||
* ETag handling is scoped to a small set of home-refresh URL patterns
|
||||
* matched against path segments. All other endpoints (per-item lookups,
|
||||
* search, sessions, system info, etc.) are passed through unchanged. This
|
||||
* keeps the 304 → [NotModifiedException] translation from leaking into code
|
||||
* paths that do not expect it, and prevents search and similar list-shaped
|
||||
* calls from being treated as cacheable.
|
||||
*
|
||||
* The cache is process-scoped: ETags are lost on process death, which means
|
||||
* the first request after each app start always goes out without
|
||||
* `If-None-Match`. This is acceptable — the server returns the same data
|
||||
* with a fresh ETag, and subsequent requests benefit from the cache.
|
||||
*/
|
||||
@Singleton
|
||||
class ETagCache @Inject constructor() {
|
||||
private val etags = ConcurrentHashMap<String, String>()
|
||||
|
||||
/**
|
||||
* Returns `true` if requests to [url] should use ETag-based conditional
|
||||
* fetching. Only home-refresh endpoints are eligible, and the
|
||||
* `/Items` list endpoint is only eligible when it is being used as a
|
||||
* per-library content query (a `parentId` query parameter is present
|
||||
* and no search-shaped parameters are set).
|
||||
*/
|
||||
fun isEligible(url: HttpUrl): Boolean = matchesHomeRefresh(url)
|
||||
|
||||
/**
|
||||
* Returns the cached ETag for [url], or `null` if none has been stored
|
||||
* yet.
|
||||
*/
|
||||
fun get(url: String): String? = etags[url]
|
||||
|
||||
/**
|
||||
* Cache [etag] for [url]. The interceptor writes here after every
|
||||
* eligible 2xx response that carried an ETag header.
|
||||
*/
|
||||
fun put(url: String, etag: String) {
|
||||
etags[url] = etag
|
||||
}
|
||||
|
||||
private fun matchesHomeRefresh(url: HttpUrl): Boolean {
|
||||
val segments = url.pathSegments
|
||||
return when {
|
||||
// /Users/{userId}/Views — get libraries
|
||||
segments.size == 3 && segments[0] == "Users" && segments[2] == "Views" -> true
|
||||
// /Items/Suggestions — get suggestions
|
||||
segments == listOf("Items", "Suggestions") -> true
|
||||
// /Users/{userId}/Items/Resume — get resume items (continue watching)
|
||||
segments.size == 4 &&
|
||||
segments[0] == "Users" &&
|
||||
segments[2] == "Items" &&
|
||||
segments[3] == "Resume" -> true
|
||||
// /Shows/NextUp — get next up episodes
|
||||
segments == listOf("Shows", "NextUp") -> true
|
||||
// /Users/{userId}/Items/Latest — latest items in a library
|
||||
segments.size == 4 &&
|
||||
segments[0] == "Users" &&
|
||||
segments[2] == "Items" &&
|
||||
segments[3] == "Latest" -> true
|
||||
// /Items — per-library content list, distinguished from search
|
||||
// by the presence of `parentId` and the absence of search params.
|
||||
segments == listOf("Items") -> isPerLibraryItemsQuery(url)
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun isPerLibraryItemsQuery(url: HttpUrl): Boolean {
|
||||
// The per-library content call always sets parentId; the search
|
||||
// calls set searchTerm or genres but never parentId. Matching
|
||||
// on parentId alone is enough to keep the ETag scope tight.
|
||||
return url.queryParameter("parentId") != null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package hu.bbara.purefin.data.jellyfin.etag
|
||||
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* OkHttp interceptor that turns Jellyfin's ETag support into a
|
||||
* "not modified" signal the SDK caller can act on.
|
||||
*
|
||||
* For URLs whose path is in [ETagCache.isEligible]:
|
||||
* - If a cached ETag exists, the request is sent with `If-None-Match`.
|
||||
* - If the server replies 304, the response is closed and a
|
||||
* [NotModifiedException] is thrown so the caller can keep using its
|
||||
* existing copy of the data.
|
||||
* - If the server replies 2xx with an ETag header, the new ETag is cached.
|
||||
*
|
||||
* All other URLs are passed through untouched. This bounds the surface
|
||||
* area of the 304 handling: only code paths that explicitly know about
|
||||
* the home-refresh contract need to catch [NotModifiedException].
|
||||
*/
|
||||
@Singleton
|
||||
class ETagInterceptor @Inject constructor(
|
||||
private val cache: ETagCache,
|
||||
) : Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
|
||||
if (!cache.isEligible(request.url)) {
|
||||
return chain.proceed(request)
|
||||
}
|
||||
|
||||
val url = request.url.toString()
|
||||
val etag = cache.get(url)
|
||||
val outgoing = if (etag != null) {
|
||||
request.newBuilder().header(HEADER_IF_NONE_MATCH, etag).build()
|
||||
} else {
|
||||
request
|
||||
}
|
||||
|
||||
val response = chain.proceed(outgoing)
|
||||
|
||||
if (response.code == HTTP_NOT_MODIFIED) {
|
||||
// Close before throwing so OkHttp does not warn about leaked
|
||||
// responses. The caller will treat this as "use the cached copy".
|
||||
response.close()
|
||||
Timber.tag(TAG).d("ETag hit for %s", url)
|
||||
throw NotModifiedException()
|
||||
}
|
||||
|
||||
// Cache the ETag for next time. Only successful responses carry
|
||||
// meaningful ETags; error responses may carry caching hints but
|
||||
// we should not treat a 5xx body as a valid cache key.
|
||||
if (response.isSuccessful) {
|
||||
response.header(HEADER_ETAG)?.let { newEtag ->
|
||||
cache.put(url, newEtag)
|
||||
}
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "ETagInterceptor"
|
||||
const val HEADER_ETAG = "ETag"
|
||||
const val HEADER_IF_NONE_MATCH = "If-None-Match"
|
||||
const val HTTP_NOT_MODIFIED = 304
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package hu.bbara.purefin.data.jellyfin.etag
|
||||
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import hu.bbara.purefin.core.jellyfin.JellyfinSdkClient
|
||||
import okhttp3.OkHttpClient
|
||||
import org.jellyfin.sdk.api.okhttp.OkHttpFactory
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Provides the [OkHttpClient] (with [ETagInterceptor] installed) and the
|
||||
* [OkHttpFactory] used by the Jellyfin SDK. The client is qualified with
|
||||
* [JellyfinSdkClient] so it is distinct from the unqualified OkHttpClient
|
||||
* in `core.jellyfin.JellyfinNetworkModule`, which is used for image and
|
||||
* media streaming. The Jellyfin SDK provides its own auth header on every
|
||||
* request, so the SDK-bound client does not need `JellyfinAuthInterceptor`.
|
||||
*/
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object JellyfinOkHttpModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@JellyfinSdkClient
|
||||
fun provideJellyfinSdkOkHttpClient(etagInterceptor: ETagInterceptor): OkHttpClient =
|
||||
OkHttpClient.Builder()
|
||||
.addInterceptor(etagInterceptor)
|
||||
.build()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@JellyfinSdkClient
|
||||
fun provideJellyfinOkHttpFactory(@JellyfinSdkClient client: OkHttpClient): OkHttpFactory =
|
||||
OkHttpFactory(base = client)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package hu.bbara.purefin.data.jellyfin.etag
|
||||
|
||||
/**
|
||||
* Thrown by [ETagInterceptor] when the Jellyfin server returns 304 Not Modified
|
||||
* for a request that had an `If-None-Match` header. Callers should treat this
|
||||
* as "the cached copy is still valid" and avoid re-processing the response.
|
||||
*
|
||||
* This is a control-flow signal, not an error — it propagates out of
|
||||
* `OkHttpClient.await()` before the SDK can deserialize a 304 body. Only
|
||||
* URLs that have been registered in [ETagCache] can produce this exception.
|
||||
*/
|
||||
class NotModifiedException : RuntimeException("ETag match — server returned 304 Not Modified")
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user