mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-24 03:36:51 +00:00
Compare commits
10 Commits
80f25c6dea
...
53a7d4ad23
| Author | SHA1 | Date | |
|---|---|---|---|
| 53a7d4ad23 | |||
| 8ac0fd0bd1 | |||
| 1910a0c144 | |||
| 217ba5b6d9 | |||
| fee0e703eb | |||
| 0fc4644fdb | |||
| 6e48b35741 | |||
| 8d5948438e | |||
| 8a2702a7cb | |||
| 5a98f3d2e7 |
@@ -41,7 +41,6 @@ fun TvEpisodeScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val episode = viewModel.episode.collectAsStateWithLifecycle()
|
val episode = viewModel.episode.collectAsStateWithLifecycle()
|
||||||
val seriesTitle = viewModel.seriesTitle.collectAsStateWithLifecycle()
|
|
||||||
val selectedEpisode = episode.value
|
val selectedEpisode = episode.value
|
||||||
|
|
||||||
if (selectedEpisode == null) {
|
if (selectedEpisode == null) {
|
||||||
@@ -51,7 +50,7 @@ fun TvEpisodeScreen(
|
|||||||
|
|
||||||
TvEpisodeScreenContent(
|
TvEpisodeScreenContent(
|
||||||
episode = selectedEpisode,
|
episode = selectedEpisode,
|
||||||
seriesTitle = seriesTitle.value,
|
seriesTitle = selectedEpisode.seriesName,
|
||||||
onPlay = remember(selectedEpisode.id, navigationManager) {
|
onPlay = remember(selectedEpisode.id, navigationManager) {
|
||||||
{
|
{
|
||||||
navigationManager.navigate(
|
navigationManager.navigate(
|
||||||
|
|||||||
@@ -244,7 +244,6 @@ fun TvPlayerScreen(
|
|||||||
.onPreviewKeyEvent { event ->
|
.onPreviewKeyEvent { event ->
|
||||||
val handled = handleTvPlayerRootKeyEvent(
|
val handled = handleTvPlayerRootKeyEvent(
|
||||||
event = event,
|
event = event,
|
||||||
isPlaying = uiState.isPlaying,
|
|
||||||
controlsVisible = controlsVisible,
|
controlsVisible = controlsVisible,
|
||||||
popupVisible = showSkipIntroButton || showNextEpisodeOverlay,
|
popupVisible = showSkipIntroButton || showNextEpisodeOverlay,
|
||||||
onShowControls = ::showControls,
|
onShowControls = ::showControls,
|
||||||
@@ -467,7 +466,6 @@ private fun HiddenTvSeekTimeline(
|
|||||||
|
|
||||||
internal fun handleTvPlayerRootKeyEvent(
|
internal fun handleTvPlayerRootKeyEvent(
|
||||||
event: KeyEvent,
|
event: KeyEvent,
|
||||||
isPlaying: Boolean,
|
|
||||||
controlsVisible: Boolean,
|
controlsVisible: Boolean,
|
||||||
popupVisible: Boolean,
|
popupVisible: Boolean,
|
||||||
isPlaylistExpanded: Boolean,
|
isPlaylistExpanded: Boolean,
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
@@ -18,13 +17,13 @@ import androidx.compose.ui.focus.FocusRequester
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.tv.material3.MaterialTheme
|
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.core.feature.content.series.SeriesViewModel
|
||||||
|
import hu.bbara.purefin.core.navigation.SeriesDto
|
||||||
import hu.bbara.purefin.model.Episode
|
import hu.bbara.purefin.model.Episode
|
||||||
import hu.bbara.purefin.model.Season
|
import hu.bbara.purefin.model.Season
|
||||||
import hu.bbara.purefin.model.Series
|
import hu.bbara.purefin.model.Series
|
||||||
import hu.bbara.purefin.core.navigation.SeriesDto
|
|
||||||
import hu.bbara.purefin.ui.common.media.MediaDetailHorizontalPadding
|
import hu.bbara.purefin.ui.common.media.MediaDetailHorizontalPadding
|
||||||
import hu.bbara.purefin.ui.common.media.TvMediaDetailBodyBox
|
import hu.bbara.purefin.ui.common.media.TvMediaDetailBodyBox
|
||||||
import hu.bbara.purefin.ui.common.media.TvMediaDetailScaffold
|
import hu.bbara.purefin.ui.common.media.TvMediaDetailScaffold
|
||||||
@@ -35,6 +34,7 @@ import hu.bbara.purefin.ui.screen.series.components.TvSeasonTabs
|
|||||||
import hu.bbara.purefin.ui.screen.series.components.TvSeriesHeroSection
|
import hu.bbara.purefin.ui.screen.series.components.TvSeriesHeroSection
|
||||||
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
|
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
import androidx.tv.material3.Text as TvText
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TvSeriesScreen(
|
fun TvSeriesScreen(
|
||||||
@@ -54,8 +54,8 @@ fun TvSeriesScreen(
|
|||||||
if (seriesData != null) {
|
if (seriesData != null) {
|
||||||
TvSeriesScreenContent(
|
TvSeriesScreenContent(
|
||||||
series = seriesData,
|
series = seriesData,
|
||||||
|
selectSeason = viewModel::selectSeason,
|
||||||
onPlayEpisode = viewModel::onPlayEpisode,
|
onPlayEpisode = viewModel::onPlayEpisode,
|
||||||
onLoadSeasonEpisodes = viewModel::loadSeasonEpisodes,
|
|
||||||
focusedSeasonId = focusedSeasonId,
|
focusedSeasonId = focusedSeasonId,
|
||||||
focusedEpisodeId = focusedEpisodeId,
|
focusedEpisodeId = focusedEpisodeId,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
@@ -68,8 +68,8 @@ fun TvSeriesScreen(
|
|||||||
@Composable
|
@Composable
|
||||||
internal fun TvSeriesScreenContent(
|
internal fun TvSeriesScreenContent(
|
||||||
series: Series,
|
series: Series,
|
||||||
|
selectSeason: (UUID, UUID) -> Unit,
|
||||||
onPlayEpisode: (UUID) -> Unit,
|
onPlayEpisode: (UUID) -> Unit,
|
||||||
onLoadSeasonEpisodes: (UUID, UUID) -> Unit = { _, _ -> },
|
|
||||||
focusedSeasonId: UUID? = null,
|
focusedSeasonId: UUID? = null,
|
||||||
focusedEpisodeId: UUID? = null,
|
focusedEpisodeId: UUID? = null,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
@@ -96,7 +96,7 @@ internal fun TvSeriesScreenContent(
|
|||||||
initialFocusSeason.episodeCount > 0
|
initialFocusSeason.episodeCount > 0
|
||||||
|
|
||||||
LaunchedEffect(series.id, selectedSeason?.id) {
|
LaunchedEffect(series.id, selectedSeason?.id) {
|
||||||
selectedSeason?.let { onLoadSeasonEpisodes(series.id, it.id) }
|
selectedSeason?.let { selectSeason(series.id, it.id) }
|
||||||
}
|
}
|
||||||
|
|
||||||
TvMediaDetailScaffold(
|
TvMediaDetailScaffold(
|
||||||
|
|||||||
@@ -220,6 +220,8 @@ private fun ColumnScope.MediaDetailScaffoldContent(
|
|||||||
color = scheme.onBackground,
|
color = scheme.onBackground,
|
||||||
fontSize = uiModel.subtitleFontSize,
|
fontSize = uiModel.subtitleFontSize,
|
||||||
fontWeight = uiModel.subtitleFontWeight,
|
fontWeight = uiModel.subtitleFontWeight,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
@@ -86,8 +87,8 @@ fun PlayerScreen(
|
|||||||
|
|
||||||
val audioManager = remember { context.getSystemService(Context.AUDIO_SERVICE) as AudioManager }
|
val audioManager = remember { context.getSystemService(Context.AUDIO_SERVICE) as AudioManager }
|
||||||
val maxVolume = remember { audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC).coerceAtLeast(1) }
|
val maxVolume = remember { audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC).coerceAtLeast(1) }
|
||||||
var volume by remember { mutableStateOf(audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) / maxVolume.toFloat()) }
|
var volume by remember { mutableFloatStateOf(audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) / maxVolume.toFloat()) }
|
||||||
var brightness by remember { mutableStateOf(readCurrentBrightness(activity)) }
|
var brightness by remember { mutableFloatStateOf(readCurrentBrightness(activity)) }
|
||||||
var showQueuePanel by remember { mutableStateOf(false) }
|
var showQueuePanel by remember { mutableStateOf(false) }
|
||||||
var horizontalSeekFeedback by remember { mutableStateOf<Long?>(null) }
|
var horizontalSeekFeedback by remember { mutableStateOf<Long?>(null) }
|
||||||
var horizontalSeekPreviewPositionMs by remember { mutableStateOf<Long?>(null) }
|
var horizontalSeekPreviewPositionMs by remember { mutableStateOf<Long?>(null) }
|
||||||
@@ -191,30 +192,30 @@ fun PlayerScreen(
|
|||||||
|
|
||||||
EmptyValueTimedVisibility(
|
EmptyValueTimedVisibility(
|
||||||
value = horizontalSeekFeedback,
|
value = horizontalSeekFeedback,
|
||||||
hideAfterMillis = 1_000
|
hideAfterMillis = 1_000,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.Center)
|
||||||
) {
|
) {
|
||||||
SeekAmountIndicator(
|
SeekAmountIndicator(
|
||||||
deltaMs = it,
|
deltaMs = it,
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.Center)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
EmptyValueTimedVisibility(
|
EmptyValueTimedVisibility(
|
||||||
value = horizontalSeekPreviewPositionMs,
|
value = horizontalSeekPreviewPositionMs,
|
||||||
hideAfterMillis = 1_000
|
hideAfterMillis = 1_000,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.padding(horizontal = 24.dp, vertical = 28.dp)
|
||||||
) { previewPositionMs ->
|
) { previewPositionMs ->
|
||||||
if (controlsVisible) {
|
if (!controlsVisible) {
|
||||||
HiddenSeekTimeline(
|
HiddenSeekTimeline(
|
||||||
positionMs = previewPositionMs,
|
positionMs = previewPositionMs,
|
||||||
durationMs = uiState.durationMs,
|
durationMs = uiState.durationMs,
|
||||||
bufferedMs = uiState.bufferedMs,
|
bufferedMs = uiState.bufferedMs,
|
||||||
chapterMarkers = uiState.chapters,
|
chapterMarkers = uiState.chapters,
|
||||||
adMarkers = uiState.ads,
|
adMarkers = uiState.ads,
|
||||||
isLive = uiState.isLive,
|
isLive = uiState.isLive
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.BottomCenter)
|
|
||||||
.padding(horizontal = 24.dp, vertical = 28.dp)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ fun SeriesScreen(
|
|||||||
|
|
||||||
SeriesScreenInternal(
|
SeriesScreenInternal(
|
||||||
series = seriesData,
|
series = seriesData,
|
||||||
|
selectSeason = viewModel::selectSeason,
|
||||||
seriesDownloadState = seriesDownloadState,
|
seriesDownloadState = seriesDownloadState,
|
||||||
seasonDownloadState = seasonDownloadState,
|
seasonDownloadState = seasonDownloadState,
|
||||||
isSmartDownloadEnabled = isSmartDownloadEnabled,
|
isSmartDownloadEnabled = isSmartDownloadEnabled,
|
||||||
@@ -113,7 +114,6 @@ fun SeriesScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onLoadSeasonEpisodes = viewModel::loadSeasonEpisodes,
|
|
||||||
onObserveSeasonDownloadState = viewModel::observeSeasonDownloadState,
|
onObserveSeasonDownloadState = viewModel::observeSeasonDownloadState,
|
||||||
onBack = viewModel::onGoHome,
|
onBack = viewModel::onGoHome,
|
||||||
onMarkAsWatched = viewModel::markAsWatched,
|
onMarkAsWatched = viewModel::markAsWatched,
|
||||||
@@ -129,11 +129,11 @@ fun SeriesScreen(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun SeriesScreenInternal(
|
private fun SeriesScreenInternal(
|
||||||
series: Series,
|
series: Series,
|
||||||
|
selectSeason: (UUID, UUID) -> Unit,
|
||||||
seriesDownloadState: DownloadState,
|
seriesDownloadState: DownloadState,
|
||||||
seasonDownloadState: DownloadState,
|
seasonDownloadState: DownloadState,
|
||||||
isSmartDownloadEnabled: Boolean,
|
isSmartDownloadEnabled: Boolean,
|
||||||
onDownloadOptionSelected: (SeriesDownloadOption, Season) -> Unit,
|
onDownloadOptionSelected: (SeriesDownloadOption, Season) -> Unit,
|
||||||
onLoadSeasonEpisodes: (UUID, UUID) -> Unit,
|
|
||||||
onObserveSeasonDownloadState: (List<Episode>) -> Unit,
|
onObserveSeasonDownloadState: (List<Episode>) -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onMarkAsWatched: (Boolean) -> Unit = {},
|
onMarkAsWatched: (Boolean) -> Unit = {},
|
||||||
@@ -175,7 +175,7 @@ private fun SeriesScreenInternal(
|
|||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(series.id, selectedSeason?.id) {
|
LaunchedEffect(series.id, selectedSeason?.id) {
|
||||||
selectedSeason?.let { onLoadSeasonEpisodes(series.id, it.id) }
|
selectedSeason?.let { selectSeason(series.id, it.id) }
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(selectedSeason?.id, selectedSeason?.episodes) {
|
LaunchedEffect(selectedSeason?.id, selectedSeason?.episodes) {
|
||||||
@@ -250,9 +250,14 @@ private fun Series.toMediaDetailScaffoldUiModel(
|
|||||||
metadataItems = listOf(year, "$seasonCount Seasons"),
|
metadataItems = listOf(year, "$seasonCount Seasons"),
|
||||||
actions = selectedSeason?.let {
|
actions = selectedSeason?.let {
|
||||||
val seriesWatched = unwatchedEpisodeCount == 0
|
val seriesWatched = unwatchedEpisodeCount == 0
|
||||||
|
val playButtonText = mediaPlayButtonText(nextUpEpisode?.progress, nextUpEpisode?.watched)
|
||||||
MediaDetailActionsUiModel(
|
MediaDetailActionsUiModel(
|
||||||
primaryAction = MediaDetailPrimaryActionUiModel(
|
primaryAction = MediaDetailPrimaryActionUiModel(
|
||||||
text = mediaPlayButtonText(nextUpEpisode?.progress, nextUpEpisode?.watched),
|
text = if (nextUpEpisode != null) {
|
||||||
|
"$playButtonText S${it.index} • E${nextUpEpisode.index}"
|
||||||
|
} else {
|
||||||
|
playButtonText
|
||||||
|
},
|
||||||
progress = mediaPlaybackProgress(nextUpEpisode?.progress),
|
progress = mediaPlaybackProgress(nextUpEpisode?.progress),
|
||||||
onClick = onPlayClick,
|
onClick = onPlayClick,
|
||||||
testTag = SeriesPlayButtonTag
|
testTag = SeriesPlayButtonTag
|
||||||
|
|||||||
@@ -8,29 +8,37 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun <T> EmptyValueTimedVisibility(
|
fun <T> EmptyValueTimedVisibility(
|
||||||
value: T?,
|
value: T?,
|
||||||
hideAfterMillis: Long = 1_000,
|
hideAfterMillis: Long = 1_000,
|
||||||
|
enterTransition: EnterTransition = EnterTransition.None,
|
||||||
|
exitTransition: ExitTransition = ExitTransition.None,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
content: @Composable (T) -> Unit
|
content: @Composable (T) -> Unit
|
||||||
) {
|
) {
|
||||||
val shownValue = remember { mutableStateOf<T?>(null) }
|
var displayedValue by remember { mutableStateOf(value) }
|
||||||
|
|
||||||
LaunchedEffect(value) {
|
LaunchedEffect(value) {
|
||||||
if (value == null) {
|
if (value != null) {
|
||||||
delay(hideAfterMillis)
|
displayedValue = value
|
||||||
shownValue.value = null
|
|
||||||
}
|
}
|
||||||
shownValue.value = value
|
|
||||||
}
|
}
|
||||||
|
if (displayedValue != null) {
|
||||||
shownValue.value?.let {
|
ValueChangeTimedVisibility(
|
||||||
content(it)
|
value = displayedValue!!,
|
||||||
|
hideAfterMillis = hideAfterMillis,
|
||||||
|
enterTransition = enterTransition,
|
||||||
|
exitTransition = exitTransition,
|
||||||
|
modifier = modifier,
|
||||||
|
content = content
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,30 +46,40 @@ fun <T> EmptyValueTimedVisibility(
|
|||||||
fun <T> ValueChangeTimedVisibility(
|
fun <T> ValueChangeTimedVisibility(
|
||||||
value: T,
|
value: T,
|
||||||
hideAfterMillis: Long = 1_000,
|
hideAfterMillis: Long = 1_000,
|
||||||
|
enterTransition: EnterTransition = EnterTransition.None,
|
||||||
|
exitTransition: ExitTransition = ExitTransition.None,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
content: @Composable (T) -> Unit
|
content: @Composable (T) -> Unit
|
||||||
) {
|
) {
|
||||||
var displayedValue by remember { mutableStateOf(value) }
|
var displayedValue by remember { mutableStateOf(value) }
|
||||||
var isVisible by remember { mutableStateOf(false) }
|
|
||||||
var hasInitialValue by remember { mutableStateOf(false) }
|
var hasInitialValue by remember { mutableStateOf(false) }
|
||||||
|
var isVisible by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
var hideJob: Job? by remember { mutableStateOf(null) }
|
||||||
|
fun restartHideJob() {
|
||||||
|
hideJob?.cancel()
|
||||||
|
hideJob = coroutineScope.launch {
|
||||||
|
delay(hideAfterMillis)
|
||||||
|
isVisible = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(value) {
|
LaunchedEffect(value) {
|
||||||
displayedValue = value
|
|
||||||
if (!hasInitialValue) {
|
if (!hasInitialValue) {
|
||||||
hasInitialValue = true
|
hasInitialValue = true
|
||||||
return@LaunchedEffect
|
return@LaunchedEffect
|
||||||
}
|
}
|
||||||
|
displayedValue = value
|
||||||
isVisible = true
|
isVisible = true
|
||||||
delay(hideAfterMillis)
|
restartHideJob()
|
||||||
isVisible = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = isVisible,
|
visible = isVisible,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
enter = EnterTransition.None,
|
enter = enterTransition,
|
||||||
exit = ExitTransition.None
|
exit = exitTransition
|
||||||
) {
|
) {
|
||||||
content(displayedValue)
|
content(displayedValue)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,27 @@ class CompositeLocalMediaRepository @Inject constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun loadMovie(id: UUID) {
|
||||||
|
runOnlineOrOfflineNoOp(
|
||||||
|
onlineAction = { onlineRepository.loadMovie(id) },
|
||||||
|
offlineAction = { offlineRepository.loadMovie(id) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun loadSeries(id: UUID) {
|
||||||
|
runOnlineOrOfflineNoOp(
|
||||||
|
onlineAction = { onlineRepository.loadSeries(id) },
|
||||||
|
offlineAction = { offlineRepository.loadSeries(id) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun loadEpisode(id: UUID) {
|
||||||
|
runOnlineOrOfflineNoOp(
|
||||||
|
onlineAction = { onlineRepository.loadEpisode(id) },
|
||||||
|
offlineAction = { offlineRepository.loadEpisode(id) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun loadSeasons(seriesId: UUID) {
|
override suspend fun loadSeasons(seriesId: UUID) {
|
||||||
runOnlineOrOfflineNoOp(
|
runOnlineOrOfflineNoOp(
|
||||||
onlineAction = { onlineRepository.loadSeasons(seriesId) },
|
onlineAction = { onlineRepository.loadSeasons(seriesId) },
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package hu.bbara.purefin.core.data
|
package hu.bbara.purefin.core.data
|
||||||
|
|
||||||
import hu.bbara.purefin.model.Episode
|
import hu.bbara.purefin.model.Episode
|
||||||
|
import hu.bbara.purefin.model.Media
|
||||||
import hu.bbara.purefin.model.Movie
|
import hu.bbara.purefin.model.Movie
|
||||||
import hu.bbara.purefin.model.Series
|
import hu.bbara.purefin.model.Series
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
@@ -11,9 +12,20 @@ interface LocalMediaRepository : MediaMetadataUpdater {
|
|||||||
val movies: StateFlow<Map<UUID, Movie>>
|
val movies: StateFlow<Map<UUID, Movie>>
|
||||||
val series: StateFlow<Map<UUID, Series>>
|
val series: StateFlow<Map<UUID, Series>>
|
||||||
val episodes: StateFlow<Map<UUID, Episode>>
|
val episodes: StateFlow<Map<UUID, Episode>>
|
||||||
|
suspend fun getTypeById(id: UUID): Media? {
|
||||||
|
return when (val media = movies.value[id] ?: series.value[id] ?: episodes.value[id]) {
|
||||||
|
is Movie -> Media.MovieMedia(movieId = media.id)
|
||||||
|
is Series -> Media.SeriesMedia(seriesId = media.id)
|
||||||
|
is Episode -> Media.EpisodeMedia(seriesId = media.seriesId, episodeId = media.id)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
suspend fun getMovie(id: UUID): Flow<Movie?>
|
suspend fun getMovie(id: UUID): Flow<Movie?>
|
||||||
suspend fun getSeries(id: UUID): Flow<Series?>
|
suspend fun getSeries(id: UUID): Flow<Series?>
|
||||||
suspend fun getEpisode(id: UUID): Flow<Episode?>
|
suspend fun getEpisode(id: UUID): Flow<Episode?>
|
||||||
|
suspend fun loadMovie(id: UUID)
|
||||||
|
suspend fun loadSeries(id: UUID)
|
||||||
|
suspend fun loadEpisode(id: UUID)
|
||||||
suspend fun loadSeasons(seriesId: UUID)
|
suspend fun loadSeasons(seriesId: UUID)
|
||||||
suspend fun loadSeasonEpisodes(seriesId: UUID, seasonId: UUID)
|
suspend fun loadSeasonEpisodes(seriesId: UUID, seasonId: UUID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -47,17 +46,6 @@ class EpisodeScreenViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
val seriesTitle: StateFlow<String?> = _episode
|
|
||||||
.flatMapLatest { episode ->
|
|
||||||
if (episode == null) {
|
|
||||||
flowOf(null)
|
|
||||||
} else {
|
|
||||||
mediaCatalogReader(episode.offline).getSeries(episode.seriesId).map { it?.name }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
|
||||||
|
|
||||||
private val _downloadState = MutableStateFlow<DownloadState>(DownloadState.NotDownloaded)
|
private val _downloadState = MutableStateFlow<DownloadState>(DownloadState.NotDownloaded)
|
||||||
val downloadState: StateFlow<DownloadState> = _downloadState.asStateFlow()
|
val downloadState: StateFlow<DownloadState> = _downloadState.asStateFlow()
|
||||||
|
|
||||||
@@ -76,6 +64,9 @@ class EpisodeScreenViewModel @Inject constructor(
|
|||||||
|
|
||||||
fun selectEpisode(episode: EpisodeDto) {
|
fun selectEpisode(episode: EpisodeDto) {
|
||||||
_episode.value = episode
|
_episode.value = episode
|
||||||
|
viewModelScope.launch {
|
||||||
|
mediaCatalogReader(episode.offline).loadEpisode(episode.id)
|
||||||
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
mediaDownloadManager.observeDownloadState(episode.id.toString()).collect {
|
mediaDownloadManager.observeDownloadState(episode.id.toString()).collect {
|
||||||
_downloadState.value = it
|
_downloadState.value = it
|
||||||
|
|||||||
@@ -63,6 +63,9 @@ class MovieScreenViewModel @Inject constructor(
|
|||||||
|
|
||||||
fun selectMovie(movie: MovieDto) {
|
fun selectMovie(movie: MovieDto) {
|
||||||
_movie.value = movie
|
_movie.value = movie
|
||||||
|
viewModelScope.launch {
|
||||||
|
mediaCatalogReader(movie.offline).loadMovie(movie.id)
|
||||||
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
mediaDownloadManager.observeDownloadState(movie.id.toString()).collect {
|
mediaDownloadManager.observeDownloadState(movie.id.toString()).collect {
|
||||||
_downloadState.value = it
|
_downloadState.value = it
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import hu.bbara.purefin.core.Offline
|
import hu.bbara.purefin.core.Offline
|
||||||
import hu.bbara.purefin.core.data.LocalMediaRepository
|
import hu.bbara.purefin.core.data.LocalMediaRepository
|
||||||
|
import hu.bbara.purefin.core.data.MediaMetadataUpdater
|
||||||
import hu.bbara.purefin.core.download.DownloadState
|
import hu.bbara.purefin.core.download.DownloadState
|
||||||
import hu.bbara.purefin.core.download.MediaDownloadController
|
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.EpisodeDto
|
||||||
import hu.bbara.purefin.core.navigation.NavigationManager
|
import hu.bbara.purefin.core.navigation.NavigationManager
|
||||||
import hu.bbara.purefin.core.navigation.Route
|
import hu.bbara.purefin.core.navigation.Route
|
||||||
@@ -98,12 +98,6 @@ class SeriesViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadSeasonEpisodes(seriesId: UUID, seasonId: UUID) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
selectedMediaCatalogReader().loadSeasonEpisodes(seriesId, seasonId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun downloadSeason(seriesId: UUID, seasonId: UUID) {
|
fun downloadSeason(seriesId: UUID, seasonId: UUID) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val mediaCatalogReader = selectedMediaCatalogReader()
|
val mediaCatalogReader = selectedMediaCatalogReader()
|
||||||
@@ -195,7 +189,15 @@ class SeriesViewModel @Inject constructor(
|
|||||||
fun selectSeries(series: SeriesDto) {
|
fun selectSeries(series: SeriesDto) {
|
||||||
_series.value = series
|
_series.value = series
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
mediaCatalogReader(series.offline).loadSeasons(series.id)
|
val mediaCatalogReader = mediaCatalogReader(series.offline)
|
||||||
|
launch { mediaCatalogReader.loadSeries(series.id) }
|
||||||
|
launch { mediaCatalogReader.loadSeasons(series.id) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectSeason(seriesId: UUID, seasonId: UUID) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
selectedMediaCatalogReader().loadSeasonEpisodes(seriesId, seasonId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -169,8 +169,7 @@ class PlayerViewModel @Inject constructor(
|
|||||||
_uiState.update { it.copy(error = dataErrorMessage) }
|
_uiState.update { it.copy(error = dataErrorMessage) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
//TODO hack to preload the series media
|
mediaCatalogReader.loadEpisode(UUID.fromString(id))
|
||||||
mediaCatalogReader.getEpisode(UUID.fromString(id))
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching {
|
runCatching {
|
||||||
playerManager.play(uuid)
|
playerManager.play(uuid)
|
||||||
@@ -243,6 +242,7 @@ class PlayerViewModel @Inject constructor(
|
|||||||
private suspend fun PlayableMedia.toPlaylistElementUiModel(currentMediaId: UUID?): PlaylistElementUiModel? {
|
private suspend fun PlayableMedia.toPlaylistElementUiModel(currentMediaId: UUID?): PlaylistElementUiModel? {
|
||||||
return when (this) {
|
return when (this) {
|
||||||
is PlayableMedia.Movie -> {
|
is PlayableMedia.Movie -> {
|
||||||
|
mediaCatalogReader.loadMovie(id)
|
||||||
val movie = mediaCatalogReader.getMovie(id).first()
|
val movie = mediaCatalogReader.getMovie(id).first()
|
||||||
if (movie == null) {
|
if (movie == null) {
|
||||||
Timber.tag(TAG).e("Movie not found for playlist item: $id")
|
Timber.tag(TAG).e("Movie not found for playlist item: $id")
|
||||||
@@ -261,6 +261,7 @@ class PlayerViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
is PlayableMedia.Series -> {
|
is PlayableMedia.Series -> {
|
||||||
|
mediaCatalogReader.loadSeries(id)
|
||||||
val series = mediaCatalogReader.getSeries(id).first()
|
val series = mediaCatalogReader.getSeries(id).first()
|
||||||
if (series == null) {
|
if (series == null) {
|
||||||
Timber.tag(TAG).e("Series not found for playlist item: $id")
|
Timber.tag(TAG).e("Series not found for playlist item: $id")
|
||||||
@@ -279,6 +280,7 @@ class PlayerViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
is PlayableMedia.Episode -> {
|
is PlayableMedia.Episode -> {
|
||||||
|
mediaCatalogReader.loadEpisode(id)
|
||||||
val episode = mediaCatalogReader.getEpisode(id).first()
|
val episode = mediaCatalogReader.getEpisode(id).first()
|
||||||
if (episode == null) {
|
if (episode == null) {
|
||||||
Timber.tag(TAG).e("Episode not found for playlist item: $id")
|
Timber.tag(TAG).e("Episode not found for playlist item: $id")
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flowOf
|
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
@@ -48,48 +47,70 @@ class InMemoryLocalMediaRepository @Inject constructor(
|
|||||||
private val episodesState = MutableStateFlow<Map<UUID, Episode>>(emptyMap())
|
private val episodesState = MutableStateFlow<Map<UUID, Episode>>(emptyMap())
|
||||||
override val episodes: StateFlow<Map<UUID, Episode>> = episodesState.asStateFlow()
|
override val episodes: StateFlow<Map<UUID, Episode>> = episodesState.asStateFlow()
|
||||||
|
|
||||||
override suspend fun getMovie(id: UUID): Flow<Movie?> {
|
override suspend fun getMovie(id: UUID): Flow<Movie?> =
|
||||||
if (!moviesState.value.containsKey(id)) {
|
moviesState.map { it[id] }.distinctUntilChanged()
|
||||||
|
|
||||||
|
override suspend fun getSeries(id: UUID): Flow<Series?> =
|
||||||
|
seriesState.map { it[id] }.distinctUntilChanged()
|
||||||
|
|
||||||
|
override suspend fun getEpisode(id: UUID): Flow<Episode?> =
|
||||||
|
episodesState.map { it[id] }.distinctUntilChanged()
|
||||||
|
|
||||||
|
override suspend fun loadMovie(id: UUID) {
|
||||||
|
if (moviesState.value.containsKey(id)) return
|
||||||
|
try {
|
||||||
jellyfinApiClient.getItemInfo(id)?.let { item ->
|
jellyfinApiClient.getItemInfo(id)?.let { item ->
|
||||||
if (item.type != BaseItemKind.MOVIE) {
|
if (item.type != BaseItemKind.MOVIE) {
|
||||||
Timber.tag(TAG).d("Item is not an movie: ${item.type}")
|
Timber.tag(TAG).d("Item is not an movie: ${item.type}")
|
||||||
return flowOf(null)
|
return
|
||||||
}
|
}
|
||||||
val movie = item.toMovie(serverUrl.first())
|
val movie = item.toMovie(serverUrl.first())
|
||||||
moviesState.update { current -> current + (movie.id to movie) }
|
moviesState.update { current -> current + (movie.id to movie) }
|
||||||
}
|
}
|
||||||
|
} catch (error: CancellationException) {
|
||||||
|
throw error
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Timber.tag(TAG).e(error, "Failed to load movie $id")
|
||||||
|
throw error
|
||||||
}
|
}
|
||||||
return moviesState.map { it[id] }.distinctUntilChanged()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getSeries(id: UUID): Flow<Series?> {
|
override suspend fun loadSeries(id: UUID) {
|
||||||
if (!seriesState.value.containsKey(id)) {
|
if (seriesState.value.containsKey(id)) return
|
||||||
|
try {
|
||||||
jellyfinApiClient.getItemInfo(id)?.let { item ->
|
jellyfinApiClient.getItemInfo(id)?.let { item ->
|
||||||
if (item.type != BaseItemKind.SERIES) {
|
if (item.type != BaseItemKind.SERIES) {
|
||||||
Timber.tag(TAG).d("Item is not an series: ${item.type}")
|
Timber.tag(TAG).d("Item is not an series: ${item.type}")
|
||||||
return flowOf(null)
|
return
|
||||||
}
|
}
|
||||||
val series = item.toSeries(serverUrl.first())
|
val series = item.toSeries(serverUrl.first())
|
||||||
seriesState.update { current -> current + (series.id to series) }
|
seriesState.update { current -> current + (series.id to series) }
|
||||||
}
|
}
|
||||||
|
} catch (error: CancellationException) {
|
||||||
|
throw error
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Timber.tag(TAG).e(error, "Failed to load series $id")
|
||||||
|
throw error
|
||||||
}
|
}
|
||||||
return seriesState.map { it[id] }.distinctUntilChanged()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getEpisode(id: UUID): Flow<Episode?> {
|
override suspend fun loadEpisode(id: UUID) {
|
||||||
if (!episodesState.value.containsKey(id)) {
|
if (episodesState.value.containsKey(id)) return
|
||||||
|
try {
|
||||||
jellyfinApiClient.getItemInfo(id)?.let { item ->
|
jellyfinApiClient.getItemInfo(id)?.let { item ->
|
||||||
if (item.type != BaseItemKind.EPISODE) {
|
if (item.type != BaseItemKind.EPISODE) {
|
||||||
Timber.tag(TAG).d("Item is not an episode: ${item.type}")
|
Timber.tag(TAG).d("Item is not an episode: ${item.type}")
|
||||||
return flowOf(null)
|
return
|
||||||
}
|
}
|
||||||
val episode = item.toEpisode(serverUrl.first())
|
val episode = item.toEpisode(serverUrl.first())
|
||||||
episodesState.update { current -> current + (episode.id to episode) }
|
episodesState.update { current -> current + (episode.id to episode) }
|
||||||
}
|
}
|
||||||
|
} catch (error: CancellationException) {
|
||||||
|
throw error
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Timber.tag(TAG).e(error, "Failed to load episode $id")
|
||||||
|
throw error
|
||||||
}
|
}
|
||||||
val episode = episodesState.value[id] ?: return flowOf(null)
|
|
||||||
preloadSeasonsForEpisode(episode.seriesId)
|
|
||||||
return episodesState.map { it[id] }.distinctUntilChanged()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun upsertMovies(movies: List<Movie>) {
|
fun upsertMovies(movies: List<Movie>) {
|
||||||
@@ -115,39 +136,49 @@ class InMemoryLocalMediaRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun preloadSeasonsForEpisode(seriesId: UUID) {
|
|
||||||
try {
|
|
||||||
loadSeasonsInternal(seriesId)
|
|
||||||
} catch (error: CancellationException) {
|
|
||||||
throw error
|
|
||||||
} catch (error: Exception) {
|
|
||||||
Timber.tag(TAG).w(error, "Unable to preload seasons for episode series $seriesId")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun loadSeasonsInternal(seriesId: UUID) {
|
private suspend fun loadSeasonsInternal(seriesId: UUID) {
|
||||||
seriesState.value[seriesId]?.takeIf { it.seasons.isNotEmpty() }?.let { return }
|
seriesState.value[seriesId]?.takeIf { it.seasons.isNotEmpty() }?.let { return }
|
||||||
|
|
||||||
val series = seriesState.value[seriesId] ?: throw RuntimeException("Series not found")
|
val seasons = jellyfinApiClient.getSeasons(seriesId).map { it.toSeason() }
|
||||||
|
|
||||||
val updatedSeries = series.copy(
|
// Await the parallel loadSeries to populate the series entry.
|
||||||
seasons = jellyfinApiClient.getSeasons(seriesId).map { it.toSeason() }
|
// Precondition: seriesId refers to a real series (selectSeries always uses a
|
||||||
)
|
// SeriesDto from a real series list). If loadSeries returns without populating
|
||||||
seriesState.update { it + (updatedSeries.id to updatedSeries) }
|
// state (null item / wrong type) this suspends until cancelled by structured
|
||||||
|
// concurrency when a sibling load* call fails.
|
||||||
|
seriesState.first { it.containsKey(seriesId) }
|
||||||
|
|
||||||
|
seriesState.update { current ->
|
||||||
|
val existing = current[seriesId] ?: return@update current
|
||||||
|
if (existing.seasons.isNotEmpty()) return@update current
|
||||||
|
current + (seriesId to existing.copy(seasons = seasons))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun loadSeasonEpisodes(seriesId: UUID, seasonId: UUID) {
|
override suspend fun loadSeasonEpisodes(seriesId: UUID, seasonId: UUID) {
|
||||||
loadSeasons(seriesId)
|
// Fast path: season already cached with episodes or known empty — skip the fetch.
|
||||||
|
seriesState.value[seriesId]?.seasons?.firstOrNull { it.id == seasonId }?.let { cached ->
|
||||||
|
if (cached.episodes.isNotEmpty() || cached.episodeCount == 0) return
|
||||||
|
}
|
||||||
|
|
||||||
val series = seriesState.value[seriesId] ?: throw RuntimeException("Series not found")
|
// Fetch first so this runs concurrently with loadSeries/loadSeasons when launched
|
||||||
|
// from selectSeries. Precondition: seasons are pre-loaded by selectSeries and
|
||||||
|
// seasonId is always a valid season from a real EpisodeDto in this path.
|
||||||
|
val serverUrl = userSessionRepository.serverUrl.first()
|
||||||
|
val episodes = jellyfinApiClient.getEpisodesInSeason(seriesId, seasonId)
|
||||||
|
.map { it.toEpisode(serverUrl) }
|
||||||
|
|
||||||
|
// Await the season to be present in state (immediate when pre-loaded; waits for
|
||||||
|
// the parallel loadSeasons otherwise).
|
||||||
|
seriesState.first { it[seriesId]?.seasons?.any { it.id == seasonId } == true }
|
||||||
|
|
||||||
|
// Re-check guard after await: a concurrent call may have filled episodes already.
|
||||||
|
val series = seriesState.value[seriesId] ?: return
|
||||||
val season = series.seasons.firstOrNull { it.id == seasonId } ?: return
|
val season = series.seasons.firstOrNull { it.id == seasonId } ?: return
|
||||||
if (season.episodes.isNotEmpty() || season.episodeCount == 0) {
|
if (season.episodes.isNotEmpty() || season.episodeCount == 0) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val serverUrl = userSessionRepository.serverUrl.first()
|
|
||||||
val episodes = jellyfinApiClient.getEpisodesInSeason(seriesId, seasonId)
|
|
||||||
.map { it.toEpisode(serverUrl) }
|
|
||||||
seriesState.update { current ->
|
seriesState.update { current ->
|
||||||
val currentSeries = current[seriesId] ?: return@update current
|
val currentSeries = current[seriesId] ?: return@update current
|
||||||
val updatedSeries = currentSeries.copy(
|
val updatedSeries = currentSeries.copy(
|
||||||
|
|||||||
@@ -46,6 +46,18 @@ class OfflineLocalMediaRepository @Inject constructor(
|
|||||||
return episodes.map { it[id] }
|
return episodes.map { it[id] }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun loadMovie(id: UUID) {
|
||||||
|
// Offline movie content is already persisted.
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun loadSeries(id: UUID) {
|
||||||
|
// Offline series content is already persisted.
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun loadEpisode(id: UUID) {
|
||||||
|
// Offline episode content is already persisted.
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun loadSeasons(seriesId: UUID) {
|
override suspend fun loadSeasons(seriesId: UUID) {
|
||||||
// Offline series content is already emitted with its saved seasons.
|
// Offline series content is already emitted with its saved seasons.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ class JellyfinApiClient @Inject constructor(
|
|||||||
apiClientFactory = okHttpFactory
|
apiClientFactory = okHttpFactory
|
||||||
}
|
}
|
||||||
|
|
||||||
private val api = jellyfin.createApi()
|
val api = jellyfin.createApi()
|
||||||
|
|
||||||
private val defaultItemFields =
|
private val defaultItemFields =
|
||||||
listOf(
|
listOf(
|
||||||
|
|||||||
@@ -2,12 +2,16 @@ package hu.bbara.purefin.data.jellyfin.session
|
|||||||
|
|
||||||
import hu.bbara.purefin.core.data.SessionBootstrapper
|
import hu.bbara.purefin.core.data.SessionBootstrapper
|
||||||
import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient
|
import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient
|
||||||
|
import hu.bbara.purefin.data.jellyfin.websocket.JellyfinWebSocketService
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
class JellyfinSessionBootstrapper @Inject constructor(
|
class JellyfinSessionBootstrapper @Inject constructor(
|
||||||
private val jellyfinApiClient: JellyfinApiClient,
|
private val jellyfinApiClient: JellyfinApiClient,
|
||||||
|
// Referenced to ensure Hilt instantiates the singleton at app startup.
|
||||||
|
// The service starts its own subscription in its init block.
|
||||||
|
private val webSocketService: JellyfinWebSocketService,
|
||||||
) : SessionBootstrapper {
|
) : SessionBootstrapper {
|
||||||
override suspend fun initialize() {
|
override suspend fun initialize() {
|
||||||
jellyfinApiClient.configureFromSession()
|
jellyfinApiClient.configureFromSession()
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package hu.bbara.purefin.data.jellyfin.websocket
|
||||||
|
|
||||||
|
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.data.jellyfin.client.JellyfinApiClient
|
||||||
|
import hu.bbara.purefin.model.Media
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.jellyfin.sdk.api.sockets.subscribe
|
||||||
|
import org.jellyfin.sdk.model.api.UserDataChangedMessage
|
||||||
|
import timber.log.Timber
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class JellyfinWebSocketService @Inject constructor(
|
||||||
|
private val jellyfinApiClient: JellyfinApiClient,
|
||||||
|
private val userSessionRepository: UserSessionRepository,
|
||||||
|
private val networkMonitor: NetworkMonitor,
|
||||||
|
private val localMediaRepository: LocalMediaRepository,
|
||||||
|
) {
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
|
init {
|
||||||
|
scope.launch {
|
||||||
|
combine(
|
||||||
|
userSessionRepository.isLoggedIn,
|
||||||
|
networkMonitor.isOnline,
|
||||||
|
) { loggedIn, online -> loggedIn && online }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
// collectLatest cancels the previous body on every new active
|
||||||
|
// value, so a logout / connectivity flap really tears down the
|
||||||
|
// in-flight subscription instead of leaving it running.
|
||||||
|
.collectLatest { active ->
|
||||||
|
if (!active) return@collectLatest
|
||||||
|
while (currentCoroutineContext().isActive) {
|
||||||
|
try {
|
||||||
|
listenForUserDataChanges()
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Timber.tag(TAG).w(error, "UserDataChanged subscription ended, retrying")
|
||||||
|
}
|
||||||
|
if (!currentCoroutineContext().isActive) break
|
||||||
|
delay(5000L)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun listenForUserDataChanges() {
|
||||||
|
jellyfinApiClient.api.webSocket
|
||||||
|
.subscribe<UserDataChangedMessage>()
|
||||||
|
.collect { message ->
|
||||||
|
val info = message.data ?: return@collect
|
||||||
|
for (entry in info.userDataList) {
|
||||||
|
val itemId = entry.itemId
|
||||||
|
localMediaRepository.getTypeById(itemId).let { media ->
|
||||||
|
when (media) {
|
||||||
|
is Media.MovieMedia -> {
|
||||||
|
localMediaRepository.loadMovie(media.id)
|
||||||
|
}
|
||||||
|
is Media.EpisodeMedia -> {
|
||||||
|
localMediaRepository.loadEpisode(media.id)
|
||||||
|
}
|
||||||
|
is Media.SeriesMedia -> {
|
||||||
|
localMediaRepository.loadSeries(media.id)
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "JellyfinWebSocket"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,6 @@ android.dependency.useConstraints=true
|
|||||||
android.r8.strictFullModeForKeepRules=false
|
android.r8.strictFullModeForKeepRules=false
|
||||||
android.builtInKotlin=false
|
android.builtInKotlin=false
|
||||||
android.newDsl=false
|
android.newDsl=false
|
||||||
purefinReleaseVersionCode=1000028
|
purefinReleaseVersionCode=1000030
|
||||||
purefinDebugVersionCode=1000003
|
purefinDebugVersionCode=1000003
|
||||||
purefinTvVersionCode=2000014
|
purefinTvVersionCode=2000014
|
||||||
|
|||||||
Reference in New Issue
Block a user